@surlymochan/evalid 0.0.0-stage → 0.4.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 (192) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +315 -2
  4. package/RELEASE_NOTES.md +124 -0
  5. package/atoms/registry.json +758 -0
  6. package/bin/xflow.js +7261 -0
  7. package/docs/adoption/README.md +128 -0
  8. package/docs/adoption/openflow-release-hardening.md +61 -0
  9. package/docs/benchmark-openflow-vs-gstack-superpowers-openspec-2026-05-25.md +81 -0
  10. package/docs/compatibility.md +52 -0
  11. package/docs/competitive-benchmark.md +108 -0
  12. package/docs/corps-operator-guide.md +75 -0
  13. package/docs/demo-proof.md +126 -0
  14. package/docs/examples-gallery.md +115 -0
  15. package/docs/fixtures/tracker-item.json +7 -0
  16. package/docs/goal-vs-codex.md +129 -0
  17. package/docs/gstack-comparison.md +88 -0
  18. package/docs/install-upgrade.md +133 -0
  19. package/docs/integrations.md +49 -0
  20. package/docs/launch-demo.md +123 -0
  21. package/docs/launch-dossier.md +180 -0
  22. package/docs/methodology.md +149 -0
  23. package/docs/npm-publish-handoff.md +96 -0
  24. package/docs/openspec-migration.md +106 -0
  25. package/docs/public-benchmark.md +68 -0
  26. package/docs/public-release.md +145 -0
  27. package/docs/quality-assessment.md +139 -0
  28. package/docs/quickstart.md +156 -0
  29. package/docs/reviewer-guide.md +43 -0
  30. package/docs/spec-kit-benchmark.md +73 -0
  31. package/docs/super-assistant-comparison.md +106 -0
  32. package/docs/superpowers-comparison.md +82 -0
  33. package/docs/team-adoption.md +123 -0
  34. package/docs/tooling-matrix.md +91 -0
  35. package/docs/walkthrough.md +76 -0
  36. package/docs/workflow-manual.md +1130 -0
  37. package/package.json +83 -4
  38. package/prompts/pencil-draft.md +46 -0
  39. package/prompts/pencil-refine.md +46 -0
  40. package/prompts/phase-artifact.md +24 -0
  41. package/prompts/phase-execution.md +32 -0
  42. package/schemas/competitor-reconstruction-contract.schema.json +91 -0
  43. package/schemas/llm-design-review-aggregate.schema.json +83 -0
  44. package/schemas/visual-benchmark.schema.json +978 -0
  45. package/schemas/workflow.schema.json +100 -0
  46. package/scripts/run-tests.mjs +23 -0
  47. package/scripts/takein_digest.py +139 -0
  48. package/specs/README.md +118 -0
  49. package/specs/architecture.md +36 -0
  50. package/specs/product.md +33 -0
  51. package/specs/workflow.md +349 -0
  52. package/src/agent_team/atoms/codex-app-bridge.js +8 -0
  53. package/src/agent_team/atoms/design/competitor-reconstruction-review.js +44 -0
  54. package/src/agent_team/atoms/design/contract-freeze.js +8 -0
  55. package/src/agent_team/atoms/design/generation-contract.js +442 -0
  56. package/src/agent_team/atoms/design/reconstruction-pack.js +246 -0
  57. package/src/agent_team/atoms/design/reference-surface-lock.js +49 -0
  58. package/src/agent_team/atoms/patch-challenge.js +19 -0
  59. package/src/agent_team/atoms/pencil/accept.js +9 -0
  60. package/src/agent_team/atoms/pencil/draft.js +365 -0
  61. package/src/agent_team/atoms/pencil/refine.js +229 -0
  62. package/src/agent_team/atoms/phase-run-dispatch.js +8 -0
  63. package/src/agent_team/atoms/team-run.js +380 -0
  64. package/src/agent_team/atoms/visual/aesthetic-review.js +231 -0
  65. package/src/agent_team/atoms/visual/benchmark-repair-plan.js +291 -0
  66. package/src/agent_team/atoms/visual/benchmark.js +1652 -0
  67. package/src/agent_team/atoms/visual/design-selection.js +23 -0
  68. package/src/agent_team/atoms/visual/direction-synthesis.js +124 -0
  69. package/src/agent_team/atoms/visual/dom-rect-export.js +129 -0
  70. package/src/agent_team/atoms/visual/image-diff.js +186 -0
  71. package/src/agent_team/atoms/visual/layout-competition.js +8 -0
  72. package/src/agent_team/atoms/visual/page-evidence-capture.js +806 -0
  73. package/src/agent_team/atoms/visual/playwright-runtime.js +8 -0
  74. package/src/agent_team/atoms/visual/review-aggregate.js +87 -0
  75. package/src/agent_team/atoms/visual/review.js +77 -0
  76. package/src/agent_team/atoms/visual/visual-report.js +197 -0
  77. package/src/agent_team/atoms/visual/visual-token-export.js +417 -0
  78. package/src/agent_team/common.js +311 -0
  79. package/src/core/atoms/agent-invoke.js +684 -0
  80. package/src/core/atoms/event/ingest.js +30 -0
  81. package/src/core/atoms/experience/digest.js +55 -0
  82. package/src/core/atoms/gate/artifact-verify.js +83 -0
  83. package/src/core/atoms/gate/competitor-contract-validate.js +119 -0
  84. package/src/core/atoms/gate/evidence-collect.js +64 -0
  85. package/src/core/atoms/gate/payload-from-phase-run.js +78 -0
  86. package/src/core/atoms/gate/visual-benchmark-validate.js +183 -0
  87. package/src/core/atoms/gate/workbench.js +79 -0
  88. package/src/core/atoms/intake/classify.js +15 -0
  89. package/src/core/atoms/intake/design-skill-select.js +16 -0
  90. package/src/core/atoms/intake/preview-or-refine.js +19 -0
  91. package/src/core/atoms/intake/recommend.js +20 -0
  92. package/src/core/atoms/intake/research-same-category.js +19 -0
  93. package/src/core/atoms/intervention/create.js +30 -0
  94. package/src/core/atoms/memory/manage.js +27 -0
  95. package/src/core/atoms/memory/mount.js +13 -0
  96. package/src/core/atoms/mission/create.js +19 -0
  97. package/src/core/atoms/mission/list.js +14 -0
  98. package/src/core/atoms/mission/show.js +26 -0
  99. package/src/core/atoms/notify/checkpoint.js +46 -0
  100. package/src/core/atoms/phase-run/complete.js +42 -0
  101. package/src/core/atoms/phase-run/fail.js +40 -0
  102. package/src/core/atoms/phase-run/persist.js +39 -0
  103. package/src/core/atoms/phase-run/start.js +30 -0
  104. package/src/core/atoms/plugin/run.js +76 -0
  105. package/src/core/atoms/policy/gate.js +90 -0
  106. package/src/core/atoms/policy/resolve.js +11 -0
  107. package/src/core/atoms/storage/sync.js +57 -0
  108. package/src/core/atoms/workflow/advance.js +25 -0
  109. package/src/core/atoms/workflow/guard.js +127 -0
  110. package/src/core/atoms/workflow/state-load.js +37 -0
  111. package/src/core/bindings.js +37 -0
  112. package/src/core/change-artifacts.js +134 -0
  113. package/src/core/competitive-score.js +265 -0
  114. package/src/core/concurrency-policy.js +35 -0
  115. package/src/core/execution-log.js +108 -0
  116. package/src/core/intake-recommendation.js +134 -0
  117. package/src/core/memory-state.js +62 -0
  118. package/src/core/mission-state.js +780 -0
  119. package/src/core/pending-gates.js +66 -0
  120. package/src/core/phase-cost.js +116 -0
  121. package/src/core/phase-run-read.js +27 -0
  122. package/src/core/policy-overlay.js +180 -0
  123. package/src/core/state-store.js +201 -0
  124. package/src/core/workflow-executor.js +1014 -0
  125. package/src/core/workflow-integrity.js +111 -0
  126. package/src/core/workflow-loader.js +163 -0
  127. package/src/core/workflow-rerun.js +84 -0
  128. package/src/core/workflow-state.js +119 -0
  129. package/src/server.js +1307 -0
  130. package/workflows/corps.yaml +481 -0
  131. package/workflows/yolo.yaml +241 -0
  132. package/xflow/README.md +76 -0
  133. package/xflow/aha/SKILL.md +82 -0
  134. package/xflow/aha/scripts/append_aha.py +50 -0
  135. package/xflow/archive/scripts/archive_change.py +338 -0
  136. package/xflow/archive/templates/merge-design.md +54 -0
  137. package/xflow/archive/templates/merge-workflow.md +26 -0
  138. package/xflow/atoms/__init__.py +6 -0
  139. package/xflow/atoms/a1_issue_create_or_patch.py +70 -0
  140. package/xflow/atoms/a2_branch_create_and_link.py +42 -0
  141. package/xflow/atoms/a3_repo_doctor.py +11 -0
  142. package/xflow/atoms/a4_project_set_status.py +11 -0
  143. package/xflow/atoms/a5_archive_commit_push_close.py +11 -0
  144. package/xflow/atoms/a6_pr_create.py +72 -0
  145. package/xflow/atoms/b1_change_scaffold.py +11 -0
  146. package/xflow/atoms/b2_status_read.py +16 -0
  147. package/xflow/atoms/b2b_status_write.py +20 -0
  148. package/xflow/atoms/b3_status_transition.py +27 -0
  149. package/xflow/atoms/b4_handoff_scaffold_or_refresh.py +11 -0
  150. package/xflow/atoms/b5_aha_append.py +11 -0
  151. package/xflow/atoms/b6_takein_digest.py +11 -0
  152. package/xflow/atoms/b6b_lookback_digest.py +11 -0
  153. package/xflow/atoms/b7_change_mission_bind.py +43 -0
  154. package/xflow/atoms/c1_mem.py +54 -0
  155. package/xflow/atoms/e6_gate_local_precheck.py +23 -0
  156. package/xflow/atoms/h1_design_lite_gate.py +74 -0
  157. package/xflow/atoms/i6a_tdd_run.py +107 -0
  158. package/xflow/atoms/i6b_tdd_proof_validate.py +184 -0
  159. package/xflow/atoms/i6c_tdd_quality_review.py +372 -0
  160. package/xflow/atoms/j1_tests_run.py +129 -0
  161. package/xflow/atoms/j4a_spec_consistency_structural.py +105 -0
  162. package/xflow/atoms/j4b_spec_delta_review.py +316 -0
  163. package/xflow/atoms/j4c_openspec_migration_map.py +121 -0
  164. package/xflow/atoms/j4d_spec_clarify_gate.py +373 -0
  165. package/xflow/atoms/j6_cr_review.py +443 -0
  166. package/xflow/atoms/k1_artifacts_complete_check.py +39 -0
  167. package/xflow/atoms/k2_merge_snippets_apply.py +11 -0
  168. package/xflow/atoms/k3_handoff_refresh.py +14 -0
  169. package/xflow/atoms/k4_mem_lesson_persist.py +65 -0
  170. package/xflow/atoms/k5_archive_publish.py +21 -0
  171. package/xflow/atoms/k6_aha_merge.py +42 -0
  172. package/xflow/atoms/tdd_lib.py +956 -0
  173. package/xflow/corps/SKILL.md +109 -0
  174. package/xflow/exec/scripts/set_issue_in_progress.py +159 -0
  175. package/xflow/goal/SKILL.md +150 -0
  176. package/xflow/handoff/SKILL.md +95 -0
  177. package/xflow/handoff/scripts/scaffold_handoff.py +304 -0
  178. package/xflow/lookback/SKILL.md +66 -0
  179. package/xflow/lookback/scripts/lookback_digest.py +46 -0
  180. package/xflow/openissue/scripts/open_issue_flow.py +438 -0
  181. package/xflow/plan/SKILL.md +47 -0
  182. package/xflow/ralph/SKILL.md +123 -0
  183. package/xflow/scripts/check_installed_xflow_skill_sync.sh +44 -0
  184. package/xflow/scripts/common.py +483 -0
  185. package/xflow/scripts/doctor.py +278 -0
  186. package/xflow/scripts/sync_installed_xflow_skill.sh +134 -0
  187. package/xflow/takein/SKILL.md +81 -0
  188. package/xflow/takein/scripts/takein_digest.py +88 -0
  189. package/xflow/yolo/SKILL.md +75 -0
  190. package/xflow/yolo/scripts/scaffold_specs_change.py +673 -0
  191. package/xflow/yolo/scripts/yolo_gate.py +312 -0
  192. package/xflow/yolo/templates/DESIGN.md +179 -0
@@ -0,0 +1,1014 @@
1
+ /**
2
+ * WorkflowExecutor — iterates phases and dispatches atoms.
3
+ *
4
+ * Gate type dispatch:
5
+ * deterministic → run gate.atom via runAtomSync(), expect exit 0 + JSON {ok: true}
6
+ * artifact-verify → check all phase.artifacts exist and are non-empty
7
+ * llm-judge → on yolo track: return {verdict: 'needs-human', reason: 'llm-judge phases require corps track'}
8
+ * on corps track: delegate to agent_team runner (D3a/I1/E4 atoms)
9
+ * human → write gate_decision row, pause and wait for xflow gate ack or manual gate override
10
+ * skip → no gate check
11
+ *
12
+ * E6 ≡ E1+E2 invariant enforced: executor routes ALL deterministic gate checks
13
+ * through E6.gate.local_precheck Python atom, never re-implementing logic here.
14
+ */
15
+
16
+ import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
17
+ import { resolve, dirname } from 'path';
18
+ import { spawnSync } from 'child_process';
19
+ import { fileURLToPath, pathToFileURL } from 'url';
20
+ import { randomUUID } from 'crypto';
21
+ import { appendExecutionLog, appendExecutionLogSync } from './execution-log.js';
22
+ import { addTokenUsage, buildPhaseCostReport, emptyTokenUsage, extractTokenUsage } from './phase-cost.js';
23
+ import { createConcurrencyPolicy } from './concurrency-policy.js';
24
+ import { buildWorkflowManifest } from './workflow-integrity.js';
25
+
26
+ const __dirname = dirname(fileURLToPath(import.meta.url));
27
+ const ROOT = resolve(__dirname, '..', '..');
28
+
29
+ function tail(value, maxChars = 4000) {
30
+ if (value == null) return null;
31
+ const text = String(value);
32
+ if (!text) return null;
33
+ return text.length > maxChars ? text.slice(-maxChars) : text;
34
+ }
35
+
36
+ export class WorkflowExecutor {
37
+ constructor({ workflow, phases, registry, workflowPath = null, projectRoot, changeId, input = {}, exitOnFailure = true, maxParallelAgentsOverride = null, abortSignal = null }) {
38
+ this.workflow = workflow;
39
+ this.phases = phases;
40
+ this.registry = registry;
41
+ this.workflowPath = workflowPath;
42
+ this.workflowManifest = buildWorkflowManifest({ workflowPath, workflow, phases, registry });
43
+ this.projectRoot = projectRoot || process.cwd();
44
+ this.changeId = changeId || input.change_id || process.env.CHANGE_ID;
45
+ this.input = input;
46
+ this.exitOnFailure = exitOnFailure;
47
+ this.stateFile = resolve(this.projectRoot, '.as-xflow', 'workflow-state.json');
48
+ this.changeStatusFile = this.changeId
49
+ ? resolve(this.projectRoot, 'specs', 'changes', this.changeId, 'status.json')
50
+ : null;
51
+ this.abortSignal = abortSignal;
52
+ this.concurrencyPolicy = createConcurrencyPolicy({ workflow, maxParallelAgentsOverride });
53
+ this.maxParallelAgents = this.concurrencyPolicy.maxAgents;
54
+ this.runtime = {
55
+ missionId: null,
56
+ workflowRunId: `wf_${randomUUID()}`,
57
+ phaseRunId: null,
58
+ phaseRunSessionId: null,
59
+ lastAtomResult: null,
60
+ atomResults: {},
61
+ phaseCosts: [],
62
+ currentPhaseTokens: null,
63
+ workflowStartedAtMs: null,
64
+ costReport: null,
65
+ };
66
+ }
67
+
68
+ // ─── Main execution loop ─────────────────────────────────────────────────
69
+
70
+ // ─── Phase cost accounting ─────────────────────────────────────────────
71
+
72
+ recordPhaseCost(phase, status, startedAtMs) {
73
+ const entry = {
74
+ phase_id: phase?.id || 'unknown',
75
+ status,
76
+ duration_ms: Math.max(0, Date.now() - startedAtMs),
77
+ tokens: this.runtime.currentPhaseTokens && this.runtime.currentPhaseTokens.total > 0
78
+ ? { ...this.runtime.currentPhaseTokens }
79
+ : null,
80
+ };
81
+ this.runtime.phaseCosts.push(entry);
82
+ this.runtime.currentPhaseTokens = emptyTokenUsage();
83
+ return entry;
84
+ }
85
+
86
+ buildCostReport() {
87
+ const wallClockMs = this.runtime.workflowStartedAtMs
88
+ ? Date.now() - this.runtime.workflowStartedAtMs
89
+ : null;
90
+ this.runtime.costReport = buildPhaseCostReport({
91
+ phases: this.runtime.phaseCosts,
92
+ wallClockMs,
93
+ });
94
+ this.persistCostReport(this.runtime.costReport);
95
+ return this.runtime.costReport;
96
+ }
97
+
98
+ // The cost report is run evidence (issue #5 P1-1): besides riding on the
99
+ // terminal execution-log event, persist it as a standalone artifact so a
100
+ // reader never has to mine the ndjson log for the per-phase breakdown.
101
+ // Best-effort by design — accounting must never fail the run itself.
102
+ persistCostReport(report) {
103
+ try {
104
+ const dir = resolve(this.projectRoot, '.as-xflow');
105
+ mkdirSync(dir, { recursive: true });
106
+ writeFileSync(resolve(dir, 'phase-cost.json'), JSON.stringify(report, null, 2));
107
+ } catch {
108
+ // The execution-log event still carries the report.
109
+ }
110
+ }
111
+
112
+ async run() {
113
+ this.runtime.workflowStartedAtMs = Date.now();
114
+ this.runtime.currentPhaseTokens = emptyTokenUsage();
115
+ const state = this.loadState();
116
+ let startIdx = 0;
117
+ const rerunRequest = state.rerun && typeof state.rerun === 'object' ? state.rerun : null;
118
+ this.ensureNotAborted();
119
+
120
+ await this.logExecutionEvent('workflow_started', {
121
+ workflow_run_id: this.runtime.workflowRunId,
122
+ workflow: this.workflow.name,
123
+ track: this.workflow.track,
124
+ workflow_integrity: this.workflowManifest,
125
+ max_parallel_agents: this.maxParallelAgents,
126
+ parallel_policy: this.concurrencyPolicy.mode,
127
+ parallel_source: this.concurrencyPolicy.source,
128
+ change_id: this.changeId || null,
129
+ mission_id: this.runtime.missionId || null,
130
+ resume_from_phase: state.last_completed_phase || null,
131
+ rerun_phase: rerunRequest?.phase_id || null,
132
+ rerun_atom: rerunRequest?.atom_id || null,
133
+ });
134
+
135
+ if (rerunRequest?.phase_id) {
136
+ const idx = this.phases.findIndex((phase) => phase.id === rerunRequest.phase_id);
137
+ if (idx >= 0) {
138
+ startIdx = idx;
139
+ console.log(`\nRerunning from phase ${startIdx + 1}/${this.phases.length} ("${rerunRequest.phase_id}")`);
140
+ }
141
+ } else if (state.last_completed_phase) {
142
+ // Resume from last completed phase
143
+ const idx = this.phases.findIndex(p => p.id === state.last_completed_phase);
144
+ if (idx >= 0) {
145
+ startIdx = idx + 1;
146
+ console.log(`\nResuming from phase ${startIdx + 1}/${this.phases.length} (after "${state.last_completed_phase}")`);
147
+ }
148
+ }
149
+
150
+ for (let i = startIdx; i < this.phases.length; i++) {
151
+ this.ensureNotAborted();
152
+ const phase = this.phases[i];
153
+ const phaseStartedAtMs = Date.now();
154
+ console.log(`\n${'═'.repeat(60)}`);
155
+ console.log(`Phase ${i + 1}/${this.phases.length}: ${phase.label || phase.id}`);
156
+ console.log('═'.repeat(60));
157
+ await this.logExecutionEvent('phase_started', {
158
+ workflow_run_id: this.runtime.workflowRunId,
159
+ workflow: this.workflow.name,
160
+ track: this.workflow.track,
161
+ phase_id: phase.id,
162
+ phase_label: phase.label || null,
163
+ phase_index: i,
164
+ change_id: this.changeId || null,
165
+ mission_id: this.runtime.missionId || null,
166
+ workflow_integrity_digest: this.workflowManifest.digest,
167
+ });
168
+
169
+ // Condition check (skip if not applicable)
170
+ if (phase.condition) {
171
+ const inputVal = this.input[phase.condition.input_field];
172
+ if (inputVal !== phase.condition.equals) {
173
+ console.log(` ↷ Skipping (condition: ${phase.condition.input_field} !== ${phase.condition.equals})`);
174
+ await this.logExecutionEvent('phase_skipped', {
175
+ workflow_run_id: this.runtime.workflowRunId,
176
+ workflow: this.workflow.name,
177
+ track: this.workflow.track,
178
+ phase_id: phase.id,
179
+ phase_label: phase.label || null,
180
+ phase_index: i,
181
+ reason: 'condition_mismatch',
182
+ change_id: this.changeId || null,
183
+ mission_id: this.runtime.missionId || null,
184
+ workflow_integrity_digest: this.workflowManifest.digest,
185
+ });
186
+ this.recordPhaseCost(phase, 'skipped', phaseStartedAtMs);
187
+ this.saveState({ ...state, last_completed_phase: phase.id });
188
+ continue;
189
+ }
190
+ }
191
+
192
+ // Preconditions
193
+ if (phase.preconditions?.length > 0) {
194
+ const precondOk = this.checkPreconditions(phase.preconditions);
195
+ if (!precondOk) {
196
+ this.recordPhaseCost(phase, 'failed', phaseStartedAtMs);
197
+ this.fail(phase, 'precondition_failed');
198
+ return;
199
+ }
200
+ }
201
+
202
+ if (phase.id === 'plan') {
203
+ this.ensurePlanArtifact();
204
+ }
205
+
206
+ // Run atoms
207
+ const atomsToRun = rerunRequest?.phase_id === phase.id && rerunRequest?.atom_id
208
+ ? (phase.atoms || []).filter((atomRef) => atomRef.id === rerunRequest.atom_id)
209
+ : (phase.atoms || []);
210
+ if (atomsToRun.length > 0) {
211
+ for (const batch of this.buildAtomBatches(atomsToRun)) {
212
+ this.ensureNotAborted();
213
+ const results = batch.parallel
214
+ ? await this.runParallelBatch(batch.atoms, phase)
215
+ : [await this.runAtom(batch.atoms[0], phase)];
216
+
217
+ for (let index = 0; index < batch.atoms.length; index += 1) {
218
+ const atomRef = batch.atoms[index];
219
+ const result = results[index];
220
+ if (!result.ok) {
221
+ console.error(` ✗ Atom ${atomRef.id} failed`);
222
+ if (phase.gate?.on_fail === 'stop') {
223
+ this.recordPhaseCost(phase, 'failed', phaseStartedAtMs);
224
+ this.fail(phase, 'atom_failed', atomRef.id);
225
+ return;
226
+ }
227
+ } else {
228
+ console.log(` ✓ Atom ${atomRef.id}`);
229
+ }
230
+ }
231
+ }
232
+ }
233
+
234
+ // Gate check
235
+ const gateResult = await this.runGate(phase);
236
+ await this.logExecutionEvent('gate_check', {
237
+ workflow_run_id: this.runtime.workflowRunId,
238
+ workflow: this.workflow.name,
239
+ track: this.workflow.track,
240
+ phase_id: phase.id,
241
+ phase_label: phase.label || null,
242
+ ok: gateResult.ok !== false,
243
+ status: gateResult.status || null,
244
+ verdict: gateResult.verdict || null,
245
+ gate_type: phase.gate?.type || 'skip',
246
+ change_id: this.changeId || null,
247
+ mission_id: this.runtime.missionId || null,
248
+ workflow_integrity_digest: this.workflowManifest.digest,
249
+ });
250
+ if (!gateResult.ok) {
251
+ const onFail = phase.gate?.on_fail || 'stop';
252
+ if (onFail === 'stop') {
253
+ this.recordPhaseCost(phase, 'failed', phaseStartedAtMs);
254
+ this.fail(phase, 'gate_failed', JSON.stringify(gateResult));
255
+ return;
256
+ } else if (onFail === 'advance-with-warning') {
257
+ console.warn(` ⚠ Gate soft-failed, advancing with warning: ${JSON.stringify(gateResult)}`);
258
+ }
259
+ } else {
260
+ console.log(` ✓ Gate: ${phase.gate?.type || 'skip'}`);
261
+ }
262
+
263
+ // Advance
264
+ state.last_completed_phase = phase.id;
265
+ state.completed_phases = [...(state.completed_phases || []), phase.id];
266
+ if (rerunRequest?.phase_id === phase.id) {
267
+ delete state.rerun;
268
+ }
269
+ state.updated_at = new Date().toISOString();
270
+ this.saveState(state);
271
+ const phaseCost = this.recordPhaseCost(phase, 'completed', phaseStartedAtMs);
272
+ await this.logExecutionEvent('phase_completed', {
273
+ workflow_run_id: this.runtime.workflowRunId,
274
+ workflow: this.workflow.name,
275
+ track: this.workflow.track,
276
+ phase_id: phase.id,
277
+ phase_label: phase.label || null,
278
+ phase_index: i,
279
+ change_id: this.changeId || null,
280
+ mission_id: this.runtime.missionId || null,
281
+ workflow_integrity_digest: this.workflowManifest.digest,
282
+ duration_ms: phaseCost.duration_ms,
283
+ cost: phaseCost,
284
+ });
285
+ }
286
+
287
+ console.log(`\n${'═'.repeat(60)}`);
288
+ console.log(`✓ Workflow "${this.workflow.name}" completed`);
289
+ console.log('═'.repeat(60));
290
+ await this.logExecutionEvent('workflow_completed', {
291
+ workflow_run_id: this.runtime.workflowRunId,
292
+ workflow: this.workflow.name,
293
+ track: this.workflow.track,
294
+ workflow_integrity: this.workflowManifest,
295
+ change_id: this.changeId || null,
296
+ mission_id: this.runtime.missionId || null,
297
+ completed_phases: this.phases.map((phase) => phase.id),
298
+ cost: this.buildCostReport(),
299
+ });
300
+ }
301
+
302
+ // ─── Atom dispatch ────────────────────────────────────────────────────────
303
+
304
+ ensurePlanArtifact() {
305
+ const changeRoot = resolve(this.projectRoot, 'specs', 'changes', this.changeId);
306
+ const planPath = resolve(changeRoot, 'plan.md');
307
+ const currentPlan = existsSync(planPath) ? readFileSync(planPath, 'utf8') : '';
308
+
309
+ if (this.isReusablePlan(currentPlan)) {
310
+ return { reused: true, generated: false, path: planPath };
311
+ }
312
+
313
+ const proposalPath = resolve(changeRoot, 'proposal.md');
314
+ const proposalText = existsSync(proposalPath) ? readFileSync(proposalPath, 'utf8') : '';
315
+ const title = this.input.title || this.workflow.name || this.changeId;
316
+ const objective = this.extractSection(proposalText, ['Objective', 'Target Outcome', 'Problem / Context', 'Problem']) || title;
317
+ const chosenPath = this.extractSection(proposalText, ['Chosen Path', 'Scope', 'In Scope']) || 'Use the accepted proposal as the implementation baseline.';
318
+ const inScope = this.extractSection(proposalText, ['In Scope', 'Scope']) || '- Follow the frozen proposal scope.';
319
+ const outOfScope = this.extractSection(proposalText, ['Out of Scope']) || '- Everything outside the accepted proposal scope.';
320
+ const verification = this.defaultVerificationCommands();
321
+ const mainRisk = this.extractSection(proposalText, ['Main Risk', 'Risks', 'Risk']) || 'Plan drift or missing verification commands will block execution.';
322
+
323
+ mkdirSync(changeRoot, { recursive: true });
324
+ const content = [
325
+ '# Plan',
326
+ '',
327
+ '## Target Outcome',
328
+ '',
329
+ objective.trim(),
330
+ '',
331
+ '## Chosen Path',
332
+ '',
333
+ chosenPath.trim(),
334
+ '',
335
+ '## In Scope',
336
+ '',
337
+ inScope.trim(),
338
+ '',
339
+ '## Out of Scope',
340
+ '',
341
+ outOfScope.trim(),
342
+ '',
343
+ '## Verification Commands',
344
+ '',
345
+ '```bash',
346
+ verification,
347
+ '```',
348
+ '',
349
+ '## Main Risk',
350
+ '',
351
+ mainRisk.trim(),
352
+ '',
353
+ ].join('\n');
354
+ writeFileSync(planPath, content, 'utf8');
355
+ return { reused: false, generated: true, path: planPath };
356
+ }
357
+
358
+ isReusablePlan(text) {
359
+ const normalized = (text || '').trim();
360
+ if (!normalized) return false;
361
+ if (normalized.length < 120) return false;
362
+ if (/## Task Slices\b/.test(normalized)) return false;
363
+ if (/^\s*-\s*$/m.test(normalized)) return false;
364
+ if (/## Verification Commands\s*\n\s*```bash\s*# Fill in the smallest proof commands\s*```/.test(normalized)) return false;
365
+ return true;
366
+ }
367
+
368
+ extractSection(text, headings) {
369
+ if (!text) return '';
370
+ const lines = text.split(/\r?\n/);
371
+ const headingSet = new Set(headings.map((heading) => heading.toLowerCase()));
372
+ let capture = false;
373
+ const collected = [];
374
+
375
+ for (const line of lines) {
376
+ const headingMatch = line.match(/^##\s+(.+)$/);
377
+ if (headingMatch) {
378
+ const current = headingMatch[1].trim().toLowerCase();
379
+ if (capture) {
380
+ break;
381
+ }
382
+ capture = headingSet.has(current);
383
+ continue;
384
+ }
385
+ if (capture) {
386
+ collected.push(line);
387
+ }
388
+ }
389
+
390
+ return collected.join('\n').trim();
391
+ }
392
+
393
+ defaultVerificationCommands() {
394
+ if (this.workflow.track === 'heavy') {
395
+ return 'npm test';
396
+ }
397
+ return 'npm test';
398
+ }
399
+
400
+ async runAtom(atomRef, phase = null) {
401
+ this.ensureNotAborted();
402
+ const def = this.registry.atoms[atomRef.id];
403
+ if (!def) {
404
+ return { ok: false, error: `Unknown atom: ${atomRef.id}` };
405
+ }
406
+
407
+ // Resolve template variables in `with`
408
+ const withArgs = this.buildAtomArgs(atomRef, phase);
409
+ const startedAt = Date.now();
410
+ const logBase = {
411
+ workflow_run_id: this.runtime.workflowRunId,
412
+ workflow: this.workflow.name,
413
+ track: this.workflow.track,
414
+ phase_id: phase?.id || null,
415
+ phase_label: phase?.label || null,
416
+ atom_id: atomRef.id,
417
+ atom_type: def.type,
418
+ parallel_group: atomRef.parallel_group || null,
419
+ parallel_weight: atomRef.parallel_group ? this.concurrencyPolicy.weightFor(atomRef, this.registry) : null,
420
+ };
421
+
422
+ if (def.type === 'python') {
423
+ const scriptPath = resolve(ROOT, def.script);
424
+ if (!existsSync(scriptPath)) {
425
+ return { ok: false, error: `Script not found: ${scriptPath}` };
426
+ }
427
+ const args = this.objectToCliArgs(withArgs);
428
+ args.push('--project-root', this.projectRoot);
429
+ if (this.changeId) args.push('--change-id', this.changeId);
430
+
431
+ console.log(` → python3 ${def.script} ${args.slice(0, 4).join(' ')}${args.length > 4 ? ' ...' : ''}`);
432
+ const result = spawnSync('python3', [scriptPath, ...args], {
433
+ stdio: ['inherit', 'pipe', 'inherit'],
434
+ encoding: 'utf8',
435
+ });
436
+ const stdout = result.stdout || '';
437
+ let parsed = {};
438
+ try { parsed = JSON.parse(stdout.trim()); } catch { parsed = { ok: result.status === 0 }; }
439
+ if (stdout.trim()) console.log(` ← ${stdout.trim().slice(0, 120)}`);
440
+ const output = { ok: result.status === 0, ...parsed };
441
+ this.captureAtomResult(atomRef.id, output);
442
+ await this.logAtomRun({
443
+ ...logBase,
444
+ ok: output.ok !== false,
445
+ exit_code: result.status ?? 1,
446
+ duration_ms: Date.now() - startedAt,
447
+ });
448
+ return output;
449
+
450
+ } else if (def.type === 'shell') {
451
+ const scriptPath = resolve(ROOT, def.script);
452
+ const args = [atomRef.with?.subcommand || '', ...(atomRef.with?.args || [])].filter(Boolean);
453
+ const result = spawnSync('bash', [scriptPath, ...args], { stdio: 'inherit' });
454
+ const output = { ok: result.status === 0 };
455
+ this.captureAtomResult(atomRef.id, output);
456
+ await this.logAtomRun({
457
+ ...logBase,
458
+ ok: output.ok !== false,
459
+ exit_code: result.status ?? 1,
460
+ duration_ms: Date.now() - startedAt,
461
+ });
462
+ return output;
463
+
464
+ } else if (def.type === 'js') {
465
+ const modulePath = resolve(ROOT, def.module);
466
+ if (!existsSync(modulePath)) {
467
+ return { ok: false, error: `Module not found: ${modulePath}` };
468
+ }
469
+ if (def.requires_http && !this.httpAvailable()) {
470
+ return { ok: false, error: `Atom ${atomRef.id} requires HTTP server (xflow serve)` };
471
+ }
472
+ const mod = await import(pathToFileURL(modulePath).href);
473
+ const handler = mod[def.export || 'default'];
474
+ if (typeof handler !== 'function') {
475
+ return { ok: false, error: `Export "${def.export || 'default'}" not found in ${def.module}` };
476
+ }
477
+ console.log(` → js ${def.module}#${def.export || 'default'}`);
478
+ const handlerInput = { ...this.input, ...withArgs };
479
+ const output = await handler(handlerInput, {
480
+ workflow: this.workflow,
481
+ phase,
482
+ registry: this.registry,
483
+ projectRoot: this.projectRoot,
484
+ changeId: this.changeId,
485
+ input: this.input,
486
+ runtime: this.runtime,
487
+ });
488
+ const normalized = output && typeof output === 'object' ? output : { ok: true, value: output };
489
+ const finalOutput = { ok: normalized.ok !== false, ...normalized };
490
+ this.captureAtomResult(atomRef.id, finalOutput);
491
+ await this.logAtomRun({
492
+ ...logBase,
493
+ ok: finalOutput.ok !== false,
494
+ status: finalOutput.status || null,
495
+ adapter: finalOutput.adapter || null,
496
+ adapter_reason: finalOutput.adapter_reason || null,
497
+ artifact_type: finalOutput.artifact?.type || null,
498
+ duration_ms: Date.now() - startedAt,
499
+ });
500
+ return finalOutput;
501
+ } else if (def.type === 'agent_invoke') {
502
+ const modPath = resolve(ROOT, def.module);
503
+ if (!existsSync(modPath)) {
504
+ return { ok: false, error: `Module not found: ${modPath}` };
505
+ }
506
+ const { default: handler } = await import(pathToFileURL(modPath).href);
507
+ console.log(` → agent_invoke ${def.module}`);
508
+ const output = await handler(withArgs, {
509
+ workflow: this.workflow,
510
+ phase,
511
+ registry: this.registry,
512
+ projectRoot: this.projectRoot,
513
+ changeId: this.changeId,
514
+ input: this.input,
515
+ runtime: this.runtime,
516
+ atomDef: { id: atomRef.id, ...def },
517
+ });
518
+ const normalized = output && typeof output === 'object' ? output : { ok: true, value: output };
519
+ const finalOutput = { ok: normalized.ok !== false, ...normalized };
520
+ this.captureAtomResult(atomRef.id, finalOutput);
521
+ await this.logAtomRun({
522
+ ...logBase,
523
+ ok: finalOutput.ok !== false,
524
+ status: finalOutput.status || null,
525
+ adapter: finalOutput.adapter || null,
526
+ adapter_reason: finalOutput.adapter_reason || null,
527
+ artifact_type: finalOutput.artifact?.type || null,
528
+ exit_code: finalOutput.code ?? null,
529
+ signal: finalOutput.signal || null,
530
+ timed_out: finalOutput.timed_out === true,
531
+ force_killed: finalOutput.force_killed === true,
532
+ attempts: finalOutput.attempts || null,
533
+ retries_used: finalOutput.retries_used || null,
534
+ error: finalOutput.error || null,
535
+ stdout_tail: tail(finalOutput.stdout),
536
+ stderr_tail: tail(finalOutput.stderr),
537
+ duration_ms: Date.now() - startedAt,
538
+ });
539
+ return finalOutput;
540
+ }
541
+
542
+ return { ok: false, error: `Unknown atom type: ${def.type}` };
543
+ }
544
+
545
+ async logAtomRun(entry) {
546
+ await this.logExecutionEvent('atom_run', entry);
547
+ }
548
+
549
+ async logExecutionEvent(kind, entry) {
550
+ try {
551
+ await appendExecutionLog(this.projectRoot, {
552
+ kind,
553
+ change_id: this.changeId || null,
554
+ mission_id: this.runtime.missionId || null,
555
+ ...entry,
556
+ });
557
+ } catch {
558
+ // Logging is best-effort and must not break workflow execution.
559
+ }
560
+ }
561
+
562
+ // ─── Gate dispatch ────────────────────────────────────────────────────────
563
+
564
+ async runGate(phase) {
565
+ const gate = phase.gate;
566
+ if (!gate || gate.type === 'skip') return { ok: true };
567
+
568
+ switch (gate.type) {
569
+ case 'deterministic': {
570
+ // Route through E6 (Python) for ALL structural checks — invariant enforced
571
+ if (gate.atom) {
572
+ const result = await this.runAtom({ id: gate.atom, with: gate.with }, phase);
573
+ return result;
574
+ }
575
+ return { ok: true }; // no gate atom = pass
576
+ }
577
+
578
+ case 'artifact-verify': {
579
+ const artifacts = phase.artifacts || [];
580
+ const missing = [];
581
+ for (const art of artifacts) {
582
+ if (art.optional) continue;
583
+ const artPath = this.resolveArtifactPath(art.path);
584
+ if (!existsSync(artPath)) {
585
+ missing.push(artPath);
586
+ } else if (statSync(artPath).size < 5) {
587
+ missing.push(`${artPath} (too small)`);
588
+ }
589
+ }
590
+ if (missing.length > 0) {
591
+ return { ok: false, verdict: 'artifact-missing', missing };
592
+ }
593
+ return { ok: true, verdict: 'pass' };
594
+ }
595
+
596
+ case 'llm-judge': {
597
+ if (this.workflow.track === 'lite') {
598
+ // Lite track cannot run LLM judge phases — requires corps
599
+ return {
600
+ ok: false,
601
+ verdict: 'needs-corps',
602
+ message: `Phase "${phase.id}" requires llm-judge gate. Use workflows/corps.yaml for this phase.`,
603
+ };
604
+ }
605
+ return this.runHeavyJudge(phase);
606
+ }
607
+
608
+ case 'human': {
609
+ // Write a gate decision record and pause
610
+ const gateFile = resolve(this.projectRoot, '.as-xflow', 'pending-gates', `${phase.id}.json`);
611
+ const autoApproveHumanGates = ['1', 'true', 'yes'].includes(String(process.env.XFLOW_AUTO_HUMAN_GATES || '').toLowerCase());
612
+ if (existsSync(gateFile)) {
613
+ try {
614
+ const pending = JSON.parse(readFileSync(gateFile, 'utf8'));
615
+ if (pending.status === 'approved') {
616
+ console.log(` ✓ Human gate approved: ${phase.id}`);
617
+ return { ok: true, verdict: 'approved', gate_file: gateFile };
618
+ }
619
+ } catch {
620
+ // Fall through and rewrite the pending gate record.
621
+ }
622
+ }
623
+ mkdirSync(dirname(gateFile), { recursive: true });
624
+ const gatePayload = autoApproveHumanGates
625
+ ? {
626
+ phase: phase.id,
627
+ status: 'approved',
628
+ created_at: new Date().toISOString(),
629
+ approved_at: new Date().toISOString(),
630
+ approved_by: 'xflow-auto-human-gate',
631
+ instructions: `Automatically approved in scripted workflow mode.`,
632
+ }
633
+ : {
634
+ phase: phase.id,
635
+ status: 'pending_human',
636
+ created_at: new Date().toISOString(),
637
+ instructions: `Review phase "${phase.id}" and run: xflow gate ack ${phase.id}`,
638
+ };
639
+ writeFileSync(gateFile, JSON.stringify(gatePayload, null, 2));
640
+ if (autoApproveHumanGates) {
641
+ console.log(` ✓ Human gate auto-approved: ${phase.id}`);
642
+ return { ok: true, verdict: 'approved', gate_file: gateFile, auto_approved: true };
643
+ }
644
+ console.log(`\n ⏸ Human gate: phase "${phase.id}"`);
645
+ console.log(` Pending gate written to: ${gateFile}`);
646
+ console.log(` To continue: xflow gate ack ${phase.id}`);
647
+ return { ok: false, verdict: 'pending_human', gate_file: gateFile };
648
+ }
649
+
650
+ default:
651
+ return { ok: false, error: `Unknown gate type: ${gate.type}` };
652
+ }
653
+ }
654
+
655
+ // ─── State management ─────────────────────────────────────────────────────
656
+
657
+ loadState() {
658
+ if (existsSync(this.stateFile)) {
659
+ try {
660
+ const parsed = JSON.parse(readFileSync(this.stateFile, 'utf8'));
661
+ if (this.changeId && parsed?.change_id && parsed.change_id !== this.changeId) {
662
+ return {
663
+ workflow: this.workflow.name,
664
+ change_id: this.changeId,
665
+ started_at: new Date().toISOString(),
666
+ completed_phases: [],
667
+ last_completed_phase: null,
668
+ };
669
+ }
670
+ if (this.changeStatusFile && !existsSync(this.changeStatusFile)) {
671
+ return {
672
+ workflow: this.workflow.name,
673
+ change_id: this.changeId,
674
+ started_at: new Date().toISOString(),
675
+ completed_phases: [],
676
+ last_completed_phase: null,
677
+ };
678
+ }
679
+ if (
680
+ parsed?.workflow_integrity?.digest
681
+ && parsed.workflow_integrity.digest !== this.workflowManifest.digest
682
+ ) {
683
+ this.fail(null, 'workflow_integrity_mismatch', `state digest ${parsed.workflow_integrity.digest} != current ${this.workflowManifest.digest}`);
684
+ return parsed;
685
+ }
686
+ return parsed;
687
+ } catch (error) {
688
+ if (error instanceof Error && /workflow_integrity_mismatch/.test(error.message)) {
689
+ throw error;
690
+ }
691
+ }
692
+ }
693
+ return {
694
+ workflow: this.workflow.name,
695
+ change_id: this.changeId,
696
+ started_at: new Date().toISOString(),
697
+ completed_phases: [],
698
+ last_completed_phase: null,
699
+ };
700
+ }
701
+
702
+ saveState(state) {
703
+ mkdirSync(dirname(this.stateFile), { recursive: true });
704
+ const payload = { ...state, updated_at: new Date().toISOString() };
705
+ payload.workflow_integrity = this.workflowManifest;
706
+ writeFileSync(this.stateFile, JSON.stringify(payload, null, 2));
707
+ if (this.changeStatusFile) {
708
+ mkdirSync(dirname(this.changeStatusFile), { recursive: true });
709
+ let existingStatus = {};
710
+ if (existsSync(this.changeStatusFile)) {
711
+ try {
712
+ existingStatus = JSON.parse(readFileSync(this.changeStatusFile, 'utf8'));
713
+ } catch {
714
+ existingStatus = {};
715
+ }
716
+ }
717
+ writeFileSync(this.changeStatusFile, JSON.stringify({
718
+ ...existingStatus,
719
+ ...payload,
720
+ }, null, 2));
721
+ }
722
+ }
723
+
724
+ fail(phase, reason, detail = '') {
725
+ try {
726
+ appendExecutionLogSync(this.projectRoot, {
727
+ kind: 'workflow_failed',
728
+ workflow_run_id: this.runtime.workflowRunId,
729
+ workflow: this.workflow.name,
730
+ track: this.workflow.track,
731
+ workflow_integrity_digest: this.workflowManifest.digest,
732
+ change_id: this.changeId || null,
733
+ mission_id: this.runtime.missionId || null,
734
+ phase_id: phase?.id || null,
735
+ phase_label: phase?.label || null,
736
+ reason,
737
+ detail: detail || null,
738
+ cost: this.buildCostReport(),
739
+ });
740
+ } catch {
741
+ // Failure logging is best-effort and must not hide the real stop reason.
742
+ }
743
+ const phaseId = phase?.id || 'workflow';
744
+ console.error(`\n✗ Workflow "${this.workflow.name}" stopped at phase "${phaseId}"`);
745
+ console.error(` Reason: ${reason}${detail ? ` — ${detail}` : ''}`);
746
+ console.error(` Resume: xflow workflow run workflows/${this.workflow.name}.yaml`);
747
+ if (this.exitOnFailure) {
748
+ process.exit(1);
749
+ }
750
+ throw new Error(`Workflow "${this.workflow.name}" stopped at phase "${phaseId}": ${reason}${detail ? ` — ${detail}` : ''}`);
751
+ }
752
+
753
+ // ─── Helpers ──────────────────────────────────────────────────────────────
754
+
755
+ resolveTemplateValue(value) {
756
+ if (typeof value === 'string') {
757
+ return value
758
+ .replace(/\$\{change_id\}/g, this.changeId || '')
759
+ .replace(/\$\{input\.(\w+)\}/g, (_, key) => this.input[key] || '')
760
+ .replace(/\$\{runtime\.mission_id\}/g, this.runtime.missionId || '')
761
+ .replace(/\$\{runtime\.phase_run_id\}/g, this.runtime.phaseRunId || '')
762
+ .replace(/\$\{runtime\.phase_run_session_id\}/g, this.runtime.phaseRunSessionId || '');
763
+ }
764
+ if (Array.isArray(value)) {
765
+ return value.map((entry) => this.resolveTemplateValue(entry));
766
+ }
767
+ if (value && typeof value === 'object') {
768
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, this.resolveTemplateValue(v)]));
769
+ }
770
+ return value;
771
+ }
772
+
773
+ resolveTemplates(obj) {
774
+ return this.resolveTemplateValue(obj || {});
775
+ }
776
+
777
+ buildAtomArgs(atomRef, phase = null) {
778
+ const withArgs = this.resolveTemplates(atomRef.with || {});
779
+ const needsMissionId = ['B7.change_mission.bind', 'D2b.mission.show', 'D3a.phase_run.start', 'D3b.phase_run.persist', 'D3c.phase_run.complete', 'D3d.phase_run.fail']
780
+ .includes(atomRef.id);
781
+ if (needsMissionId && !withArgs.mission_id && this.runtime.missionId) {
782
+ withArgs.mission_id = this.runtime.missionId;
783
+ }
784
+ if (['D3b.phase_run.persist', 'D3c.phase_run.complete', 'D3d.phase_run.fail'].includes(atomRef.id)) {
785
+ if (!withArgs.session_id && this.runtime.phaseRunSessionId) {
786
+ withArgs.session_id = this.runtime.phaseRunSessionId;
787
+ }
788
+ if (!withArgs.phase_run_id && this.runtime.phaseRunId) {
789
+ withArgs.phase_run_id = this.runtime.phaseRunId;
790
+ }
791
+ }
792
+ if (!withArgs.phase && phase?.id && atomRef.id.startsWith('D3')) {
793
+ withArgs.phase = phase.id;
794
+ }
795
+ return withArgs;
796
+ }
797
+
798
+ buildAtomBatches(atomRefs = []) {
799
+ const batches = [];
800
+ let currentParallelGroup = null;
801
+ let currentBatch = null;
802
+
803
+ for (const atomRef of atomRefs) {
804
+ const group = atomRef.parallel_group || null;
805
+ if (!group) {
806
+ currentParallelGroup = null;
807
+ currentBatch = null;
808
+ batches.push({ parallel: false, atoms: [atomRef] });
809
+ continue;
810
+ }
811
+
812
+ if (currentParallelGroup !== group || !currentBatch) {
813
+ currentParallelGroup = group;
814
+ currentBatch = { parallel: true, group, atoms: [atomRef] };
815
+ batches.push(currentBatch);
816
+ } else {
817
+ currentBatch.atoms.push(atomRef);
818
+ }
819
+ }
820
+
821
+ return batches;
822
+ }
823
+
824
+ async runParallelBatch(atomRefs = [], phase = null) {
825
+ const results = new Array(atomRefs.length);
826
+ let cursor = 0;
827
+ let running = 0;
828
+ let usedBudget = 0;
829
+ let completed = 0;
830
+
831
+ return new Promise((resolveBatch, rejectBatch) => {
832
+ const launchReady = () => {
833
+ this.ensureNotAborted();
834
+ if (completed >= atomRefs.length) {
835
+ resolveBatch(results);
836
+ return;
837
+ }
838
+
839
+ while (cursor < atomRefs.length) {
840
+ const atomRef = atomRefs[cursor];
841
+ const charge = this.concurrencyPolicy.chargeFor(atomRef, this.registry);
842
+ if (running > 0 && usedBudget + charge > this.maxParallelAgents) {
843
+ break;
844
+ }
845
+
846
+ const index = cursor;
847
+ cursor += 1;
848
+ running += 1;
849
+ usedBudget += charge;
850
+
851
+ Promise.resolve()
852
+ .then(() => {
853
+ this.ensureNotAborted();
854
+ return this.runAtom(atomRef, phase);
855
+ })
856
+ .then((result) => {
857
+ results[index] = result;
858
+ })
859
+ .catch(rejectBatch)
860
+ .finally(() => {
861
+ running -= 1;
862
+ usedBudget -= charge;
863
+ completed += 1;
864
+ launchReady();
865
+ });
866
+ }
867
+ };
868
+
869
+ try {
870
+ launchReady();
871
+ } catch (error) {
872
+ rejectBatch(error);
873
+ }
874
+ });
875
+ }
876
+
877
+ captureAtomResult(atomId, result) {
878
+ this.runtime.lastAtomResult = result;
879
+ this.runtime.atomResults[atomId] = result;
880
+ if (this.runtime.currentPhaseTokens) {
881
+ addTokenUsage(this.runtime.currentPhaseTokens, extractTokenUsage(result));
882
+ }
883
+ this.runtime.missionId = result.mission?.mission_id || result.mission_id || result.binding?.mission_id || this.runtime.missionId;
884
+ this.runtime.phaseRunId = result.phase_run?.phase_run_id || result.phase_run_id || this.runtime.phaseRunId;
885
+ this.runtime.phaseRunSessionId = result.session?.session_id || result.phase_run_session?.session_id || this.runtime.phaseRunSessionId;
886
+ }
887
+
888
+ resolveArtifactPath(pathTemplate) {
889
+ const resolved = pathTemplate
890
+ .replace(/\$\{change_id\}/g, this.changeId || '')
891
+ .replace(/\$\{input\.(\w+)\}/g, (_, key) => this.input[key] || '');
892
+ return resolve(this.projectRoot, resolved);
893
+ }
894
+
895
+ ensureNotAborted() {
896
+ if (this.abortSignal?.aborted) {
897
+ const error = new Error('Workflow execution canceled');
898
+ error.name = 'AbortError';
899
+ throw error;
900
+ }
901
+ }
902
+
903
+ loadArtifactPayload(artPath) {
904
+ if (!existsSync(artPath)) {
905
+ return null;
906
+ }
907
+ try {
908
+ const raw = readFileSync(artPath, 'utf8');
909
+ return raw.trim() ? JSON.parse(raw) : null;
910
+ } catch {
911
+ return null;
912
+ }
913
+ }
914
+
915
+ deriveJudgeVerdict(payload, artPath) {
916
+ if (!payload || typeof payload !== 'object') {
917
+ return null;
918
+ }
919
+
920
+ const explicitVerdict = payload.team_run?.verdict?.status
921
+ || payload.verdict?.status
922
+ || payload.qa_status
923
+ || payload.recommendation
924
+ || payload.status
925
+ || null;
926
+
927
+ if (explicitVerdict) {
928
+ const normalized = String(explicitVerdict).toLowerCase();
929
+ if (['accept', 'accepted', 'pass', 'passed', 'completed', 'ready', 'active'].includes(normalized)) {
930
+ return { ok: true, verdict: 'pass', source: artPath };
931
+ }
932
+ if (['reject', 'rejected', 'fail', 'failed', 'blocked'].includes(normalized)) {
933
+ return { ok: false, verdict: 'fail', source: artPath };
934
+ }
935
+ if (['needs_human', 'pending_human'].includes(normalized)) {
936
+ return { ok: false, verdict: 'needs_human', source: artPath };
937
+ }
938
+ }
939
+
940
+ const rationale = payload.team_run?.verdict?.rationale || payload.summary || '';
941
+ if (typeof rationale === 'string' && rationale.trim()) {
942
+ return { ok: true, verdict: 'pass', source: artPath };
943
+ }
944
+
945
+ return null;
946
+ }
947
+
948
+ runHeavyJudge(phase) {
949
+ const candidates = [];
950
+
951
+ for (const art of (phase.artifacts || [])) {
952
+ candidates.push(this.resolveArtifactPath(art.path));
953
+ }
954
+
955
+ candidates.push(resolve(this.projectRoot, 'specs', 'changes', this.changeId || '', `${phase.id}.json`));
956
+ const uniqueCandidates = [...new Set(candidates.filter(Boolean))];
957
+
958
+ for (const artPath of uniqueCandidates) {
959
+ const payload = this.loadArtifactPayload(artPath);
960
+ const derived = this.deriveJudgeVerdict(payload, artPath);
961
+ if (derived) {
962
+ return derived;
963
+ }
964
+ }
965
+
966
+ const existing = uniqueCandidates.find((artPath) => existsSync(artPath));
967
+ if (existing) {
968
+ return { ok: true, verdict: 'pass', source: existing, message: 'Artifact exists; defaulting heavy judge to pass.' };
969
+ }
970
+
971
+ return {
972
+ ok: false,
973
+ verdict: 'needs_human',
974
+ message: `No artifact evidence found for llm-judge phase "${phase.id}"`,
975
+ };
976
+ }
977
+
978
+ objectToCliArgs(obj) {
979
+ const args = [];
980
+ for (const [k, v] of Object.entries(obj || {})) {
981
+ if (v === null || v === undefined || v === '') continue;
982
+ const flag = `--${k.replace(/_/g, '-')}`;
983
+ if (typeof v === 'boolean') {
984
+ if (v) args.push(flag);
985
+ } else if (Array.isArray(v)) {
986
+ for (const item of v) args.push(flag, String(item));
987
+ } else if (typeof v === 'object') {
988
+ args.push(flag, JSON.stringify(v));
989
+ } else {
990
+ args.push(flag, String(v));
991
+ }
992
+ }
993
+ return args;
994
+ }
995
+
996
+ checkPreconditions(preconditions) {
997
+ for (const pre of preconditions) {
998
+ if (pre.phase_artifact) {
999
+ const artPath = this.resolveArtifactPath(pre.phase_artifact);
1000
+ if (pre.predicate === 'exists' && !existsSync(artPath)) {
1001
+ console.error(` Precondition failed: ${artPath} does not exist`);
1002
+ return false;
1003
+ }
1004
+ }
1005
+ }
1006
+ return true;
1007
+ }
1008
+
1009
+ httpAvailable() {
1010
+ // Check if agentos HTTP server is reachable
1011
+ // For now, assume available if AGENTOS_HTTP env is set
1012
+ return !!process.env.AGENTOS_HTTP || !!process.env.AS_XFLOW_HTTP;
1013
+ }
1014
+ }