codeharness 0.31.6 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2895,7 +2895,7 @@ function generateDockerfileTemplate(projectDir, stackOrDetections) {
2895
2895
  }
2896
2896
 
2897
2897
  // src/modules/infra/init-project.ts
2898
- var HARNESS_VERSION = true ? "0.31.6" : "0.0.0-dev";
2898
+ var HARNESS_VERSION = true ? "0.32.0" : "0.0.0-dev";
2899
2899
  function failResult(opts, error) {
2900
2900
  return {
2901
2901
  status: "fail",
@@ -16,7 +16,7 @@ import {
16
16
  stopCollectorOnly,
17
17
  stopSharedStack,
18
18
  stopStack
19
- } from "./chunk-ZLOBOEIA.js";
19
+ } from "./chunk-IZWRIAGV.js";
20
20
  export {
21
21
  checkRemoteEndpoint,
22
22
  cleanupOrphanedContainers,
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  validateDockerfile,
41
41
  warn,
42
42
  writeState
43
- } from "./chunk-ZLOBOEIA.js";
43
+ } from "./chunk-IZWRIAGV.js";
44
44
 
45
45
  // src/index.ts
46
46
  import { Command } from "commander";
@@ -1604,7 +1604,7 @@ var workflow_schema_default = {
1604
1604
  title: "Codeharness Workflow",
1605
1605
  description: "Schema for codeharness workflow YAML files",
1606
1606
  type: "object",
1607
- required: ["tasks"],
1607
+ required: ["tasks", "story_flow", "epic_flow"],
1608
1608
  properties: {
1609
1609
  tasks: {
1610
1610
  type: "object",
@@ -1706,12 +1706,6 @@ var workflow_schema_default = {
1706
1706
  ],
1707
1707
  description: "Agent identifier for this task (null for engine-handled tasks)"
1708
1708
  },
1709
- scope: {
1710
- type: "string",
1711
- enum: ["per-story", "per-run", "per-epic"],
1712
- default: "per-story",
1713
- description: "Execution scope \u2014 per-story, per-run, or per-epic"
1714
- },
1715
1709
  session: {
1716
1710
  type: "string",
1717
1711
  enum: ["fresh", "continue"],
@@ -2121,7 +2115,7 @@ ${bullets}`);
2121
2115
  }
2122
2116
 
2123
2117
  // src/lib/hierarchical-flow.ts
2124
- var BUILTIN_EPIC_FLOW_TASKS = /* @__PURE__ */ new Set(["merge", "validate"]);
2118
+ var BUILTIN_EPIC_FLOW_TASKS = /* @__PURE__ */ new Set(["merge", "validate", "story_flow"]);
2125
2119
  var EXECUTION_DEFAULTS = {
2126
2120
  max_parallel: 1,
2127
2121
  isolation: "none",
@@ -2134,18 +2128,25 @@ var VALID_MERGE_STRATEGY = /* @__PURE__ */ new Set(["rebase", "merge-commit"]);
2134
2128
  var VALID_EPIC_STRATEGY = /* @__PURE__ */ new Set(["parallel", "sequential"]);
2135
2129
  var VALID_STORY_STRATEGY = /* @__PURE__ */ new Set(["sequential", "parallel"]);
2136
2130
  function resolveHierarchicalFlow(parsed, resolvedTasks) {
2137
- const hasFlow = "flow" in parsed && parsed.flow !== void 0;
2138
2131
  const hasStoryFlow = "story_flow" in parsed && parsed.story_flow !== void 0;
2139
- if (hasFlow && hasStoryFlow) {
2140
- throw new HierarchicalFlowError(
2141
- 'Workflow cannot have both "flow" and "story_flow" \u2014 use "story_flow" for hierarchical workflows or "flow" for legacy mode'
2142
- );
2132
+ const hasEpicFlow = "epic_flow" in parsed && parsed.epic_flow !== void 0;
2133
+ if (!hasStoryFlow) {
2134
+ throw new HierarchicalFlowError('Workflow must define "story_flow"');
2135
+ }
2136
+ if (!hasEpicFlow) {
2137
+ throw new HierarchicalFlowError('Workflow must define "epic_flow"');
2143
2138
  }
2144
2139
  const rawExecution = parsed.execution != null && typeof parsed.execution === "object" ? parsed.execution : {};
2145
2140
  const execution = resolveExecutionConfig(rawExecution);
2146
- const rawStoryFlow = hasStoryFlow ? parsed.story_flow : parsed.flow;
2147
- const storyFlow = normalizeFlowArray(rawStoryFlow);
2141
+ const storyFlow = normalizeFlowArray(parsed.story_flow);
2148
2142
  const epicFlow = normalizeFlowArray(parsed.epic_flow);
2143
+ const storyFlowRefs = epicFlow.filter((s) => s === "story_flow");
2144
+ if (storyFlowRefs.length === 0) {
2145
+ throw new HierarchicalFlowError('epic_flow must contain a "story_flow" reference');
2146
+ }
2147
+ if (storyFlowRefs.length > 1) {
2148
+ throw new HierarchicalFlowError('epic_flow must contain exactly one "story_flow" reference');
2149
+ }
2149
2150
  return {
2150
2151
  execution,
2151
2152
  storyFlow,
@@ -2286,23 +2287,20 @@ function validateAndResolve(parsed) {
2286
2287
  );
2287
2288
  }
2288
2289
  const data = parsed;
2289
- const hasFlow = "flow" in data && data.flow !== void 0;
2290
2290
  const hasStoryFlow = "story_flow" in data && data.story_flow !== void 0;
2291
- if (hasFlow && hasStoryFlow) {
2292
- throw new WorkflowParseError(
2293
- 'Workflow cannot have both "flow" and "story_flow" \u2014 use "story_flow" for hierarchical workflows or "flow" for legacy mode',
2294
- [{ path: "/", message: 'Cannot have both "flow" and "story_flow"' }]
2295
- );
2291
+ const hasEpicFlow = "epic_flow" in data && data.epic_flow !== void 0;
2292
+ if (!hasStoryFlow) {
2293
+ throw new WorkflowParseError('Workflow must define "story_flow"', [{ path: "/", message: 'Missing "story_flow"' }]);
2296
2294
  }
2297
- const effectiveStoryFlow = (hasStoryFlow ? data.story_flow : data.flow) ?? [];
2295
+ if (!hasEpicFlow) {
2296
+ throw new WorkflowParseError('Workflow must define "epic_flow"', [{ path: "/", message: 'Missing "epic_flow"' }]);
2297
+ }
2298
+ const effectiveStoryFlow = data.story_flow ?? [];
2298
2299
  const effectiveEpicFlow = data.epic_flow ?? [];
2299
2300
  const taskNames = new Set(Object.keys(data.tasks));
2300
2301
  const allErrors = [];
2301
- const storyFlowLabel = hasStoryFlow ? "story_flow" : "flow";
2302
- validateFlowReferences(effectiveStoryFlow, taskNames, storyFlowLabel, allErrors, false);
2303
- if (effectiveEpicFlow.length > 0) {
2304
- validateFlowReferences(effectiveEpicFlow, taskNames, "epic_flow", allErrors, true);
2305
- }
2302
+ validateFlowReferences(effectiveStoryFlow, taskNames, "story_flow", allErrors, false);
2303
+ validateFlowReferences(effectiveEpicFlow, taskNames, "epic_flow", allErrors, true);
2306
2304
  validateReferentialIntegrity(data, allErrors);
2307
2305
  if (allErrors.length > 0) {
2308
2306
  const details = allErrors.map((e) => e.message).join("; ");
@@ -2312,7 +2310,6 @@ function validateAndResolve(parsed) {
2312
2310
  for (const [taskName, task] of Object.entries(data.tasks)) {
2313
2311
  const resolved = {
2314
2312
  agent: task.agent,
2315
- scope: task.scope ?? "per-story",
2316
2313
  session: task.session ?? "fresh",
2317
2314
  source_access: task.source_access ?? true
2318
2315
  };
@@ -2348,13 +2345,13 @@ function validateAndResolve(parsed) {
2348
2345
  }
2349
2346
  throw err;
2350
2347
  }
2351
- const resolvedFlow = hierarchical.storyFlow;
2352
2348
  return {
2353
2349
  tasks: resolvedTasks,
2354
- flow: resolvedFlow,
2355
- execution: hierarchical.execution,
2356
2350
  storyFlow: hierarchical.storyFlow,
2357
- epicFlow: hierarchical.epicFlow
2351
+ epicFlow: hierarchical.epicFlow,
2352
+ execution: hierarchical.execution,
2353
+ flow: hierarchical.storyFlow
2354
+ // deprecated compat
2358
2355
  };
2359
2356
  }
2360
2357
  function validateFlowReferences(flow, taskNames, flowLabel, errors, allowBuiltins) {
@@ -3561,7 +3558,7 @@ function getFailedItems(verdict, allItems) {
3561
3558
  if (verdict.verdict === "pass") return [];
3562
3559
  return allItems;
3563
3560
  }
3564
- async function executeLoopBlock(loopBlock, state, config, workItems, initialContract) {
3561
+ async function executeLoopBlock(loopBlock, state, config, workItems, initialContract, storyFlowTasks) {
3565
3562
  const projectDir = config.projectDir ?? process.cwd();
3566
3563
  const maxIterations = config.maxIterations ?? DEFAULT_MAX_ITERATIONS;
3567
3564
  const errors = [];
@@ -3578,7 +3575,7 @@ async function executeLoopBlock(loopBlock, state, config, workItems, initialCont
3578
3575
  const allCurrentIterationDone = currentState.iteration > 0 && loopBlock.loop.every((tn) => {
3579
3576
  const t = config.workflow.tasks[tn];
3580
3577
  if (!t) return true;
3581
- if (t.scope === "per-story") {
3578
+ if (storyFlowTasks?.has(tn)) {
3582
3579
  return workItems.every((item) => isLoopTaskCompleted(currentState, tn, item.key, currentState.iteration));
3583
3580
  }
3584
3581
  return isLoopTaskCompleted(currentState, tn, PER_RUN_SENTINEL, currentState.iteration);
@@ -3598,7 +3595,7 @@ async function executeLoopBlock(loopBlock, state, config, workItems, initialCont
3598
3595
  continue;
3599
3596
  }
3600
3597
  if (task.agent === null) {
3601
- if (task.scope === "per-story") {
3598
+ if (storyFlowTasks?.has(taskName)) {
3602
3599
  const itemsToProcess = lastVerdict ? getFailedItems(lastVerdict, workItems) : workItems;
3603
3600
  for (const item of itemsToProcess) {
3604
3601
  if (isLoopTaskCompleted(currentState, taskName, item.key, currentState.iteration)) {
@@ -3657,7 +3654,7 @@ async function executeLoopBlock(loopBlock, state, config, workItems, initialCont
3657
3654
  warn(`workflow-engine: agent "${task.agent}" not found for task "${taskName}", skipping`);
3658
3655
  continue;
3659
3656
  }
3660
- if (task.scope === "per-story") {
3657
+ if (storyFlowTasks?.has(taskName)) {
3661
3658
  const itemsToRetry = lastVerdict ? getFailedItems(lastVerdict, workItems) : workItems;
3662
3659
  for (const item of itemsToRetry) {
3663
3660
  if (isLoopTaskCompleted(currentState, taskName, item.key, currentState.iteration)) {
@@ -3911,298 +3908,125 @@ async function executeWorkflow(config) {
3911
3908
  warn(cw.message);
3912
3909
  }
3913
3910
  const workItems = loadWorkItems(config.sprintStatusPath, config.issuesPath);
3911
+ const storyFlowTasks = /* @__PURE__ */ new Set();
3912
+ for (const step of config.workflow.storyFlow) {
3913
+ if (typeof step === "string") storyFlowTasks.add(step);
3914
+ }
3914
3915
  let halted = false;
3915
3916
  let lastOutputContract = null;
3916
3917
  let accumulatedCostUsd = 0;
3917
- if (config.storyPipeline) {
3918
- const preTasks = [];
3919
- let loopIdx = -1;
3920
- for (let i = 0; i < config.workflow.storyFlow.length; i++) {
3921
- const s = config.workflow.storyFlow[i];
3922
- if (isLoopBlock(s)) {
3923
- loopIdx = i;
3924
- break;
3925
- }
3926
- if (typeof s === "string") preTasks.push(s);
3918
+ for (const step of config.workflow.epicFlow) {
3919
+ if (halted) break;
3920
+ if (config.abortSignal?.aborted) {
3921
+ info("Execution interrupted \u2014 saving state");
3922
+ state = { ...state, phase: "interrupted" };
3923
+ writeWorkflowState(state, projectDir);
3924
+ halted = true;
3925
+ break;
3927
3926
  }
3928
- for (const item of workItems) {
3929
- if (halted || config.abortSignal?.aborted) {
3930
- if (config.abortSignal?.aborted) {
3931
- info("Execution interrupted \u2014 saving state");
3932
- state = { ...state, phase: "interrupted" };
3933
- writeWorkflowState(state, projectDir);
3934
- }
3935
- halted = true;
3936
- break;
3937
- }
3938
- processedStories.add(item.key);
3939
- for (const taskName of preTasks) {
3927
+ if (step === "story_flow") {
3928
+ for (const item of workItems) {
3940
3929
  if (halted || config.abortSignal?.aborted) {
3941
- halted = true;
3942
- break;
3943
- }
3944
- const task = config.workflow.tasks[taskName];
3945
- if (!task || task.agent === null) continue;
3946
- const definition = config.agents[task.agent];
3947
- if (!definition) {
3948
- warn(`workflow-engine: agent "${task.agent}" not found for "${taskName}"`);
3949
- continue;
3950
- }
3951
- if (isTaskCompleted(state, taskName, item.key)) continue;
3952
- try {
3953
- const dr = await dispatchTaskWithResult(task, taskName, item.key, definition, state, config, void 0, lastOutputContract ?? void 0);
3954
- state = dr.updatedState;
3955
- lastOutputContract = dr.contract;
3956
- propagateVerifyFlags(taskName, dr.contract, projectDir);
3957
- accumulatedCostUsd += dr.contract?.cost_usd ?? 0;
3958
- tasksCompleted++;
3959
- } catch (err) {
3960
- const engineError = handleDispatchError(err, taskName, item.key);
3961
- errors.push(engineError);
3962
- if (config.onEvent) {
3963
- config.onEvent({ type: "dispatch-error", taskName, storyKey: item.key, error: { code: engineError.code, message: engineError.message } });
3964
- } else {
3965
- warn(`[${taskName}] ${item.key} \u2014 ERROR: [${engineError.code}] ${engineError.message}`);
3966
- }
3967
- state = recordErrorInState(state, taskName, item.key, engineError);
3968
- writeWorkflowState(state, projectDir);
3969
- if (err instanceof DispatchError && HALT_ERROR_CODES.has(err.code)) {
3970
- halted = true;
3971
- }
3972
- break;
3973
- }
3974
- }
3975
- }
3976
- const remaining = loopIdx >= 0 ? config.workflow.storyFlow.slice(loopIdx) : [];
3977
- for (const step of remaining) {
3978
- if (halted || config.abortSignal?.aborted) {
3979
- halted = true;
3980
- break;
3981
- }
3982
- if (isLoopBlock(step)) {
3983
- const loopResult = await executeLoopBlock(step, state, config, workItems, lastOutputContract);
3984
- state = loopResult.state;
3985
- errors.push(...loopResult.errors);
3986
- tasksCompleted += loopResult.tasksCompleted;
3987
- lastOutputContract = loopResult.lastContract;
3988
- for (const item of workItems) processedStories.add(item.key);
3989
- if (loopResult.halted || state.phase === "max-iterations" || state.phase === "circuit-breaker") {
3990
- halted = true;
3991
- }
3992
- continue;
3993
- }
3994
- const taskName = step;
3995
- const task = config.workflow.tasks[taskName];
3996
- if (!task) continue;
3997
- if (task.agent === null) continue;
3998
- const definition = config.agents[task.agent];
3999
- if (!definition) continue;
4000
- if (task.scope === "per-run" || task.scope === "per-epic") {
4001
- if (isTaskCompleted(state, taskName, PER_RUN_SENTINEL)) continue;
4002
- try {
4003
- const dr = await dispatchTaskWithResult(task, taskName, PER_RUN_SENTINEL, definition, state, config, void 0, lastOutputContract ?? void 0);
4004
- state = dr.updatedState;
4005
- lastOutputContract = dr.contract;
4006
- tasksCompleted++;
4007
- } catch (err) {
4008
- const engineError = handleDispatchError(err, taskName, PER_RUN_SENTINEL);
4009
- errors.push(engineError);
4010
- state = recordErrorInState(state, taskName, PER_RUN_SENTINEL, engineError);
4011
- writeWorkflowState(state, projectDir);
4012
- }
4013
- } else {
4014
- for (const item of workItems) {
4015
- if (halted || config.abortSignal?.aborted) break;
4016
- if (isTaskCompleted(state, taskName, item.key)) continue;
4017
- try {
4018
- const dr = await dispatchTaskWithResult(task, taskName, item.key, definition, state, config, void 0, lastOutputContract ?? void 0);
4019
- state = dr.updatedState;
4020
- lastOutputContract = dr.contract;
4021
- tasksCompleted++;
4022
- } catch (err) {
4023
- const engineError = handleDispatchError(err, taskName, item.key);
4024
- errors.push(engineError);
4025
- state = recordErrorInState(state, taskName, item.key, engineError);
3930
+ if (config.abortSignal?.aborted) {
3931
+ state = { ...state, phase: "interrupted" };
4026
3932
  writeWorkflowState(state, projectDir);
4027
3933
  }
4028
- }
4029
- }
4030
- }
4031
- }
4032
- if (!config.storyPipeline)
4033
- for (const step of config.workflow.storyFlow) {
4034
- if (halted) break;
4035
- if (config.abortSignal?.aborted) {
4036
- info("Execution interrupted \u2014 saving state");
4037
- state = { ...state, phase: "interrupted" };
4038
- writeWorkflowState(state, projectDir);
4039
- halted = true;
4040
- break;
4041
- }
4042
- if (isLoopBlock(step)) {
4043
- const loopResult = await executeLoopBlock(step, state, config, workItems, lastOutputContract);
4044
- state = loopResult.state;
4045
- errors.push(...loopResult.errors);
4046
- tasksCompleted += loopResult.tasksCompleted;
4047
- lastOutputContract = loopResult.lastContract;
4048
- for (const item of workItems) {
4049
- processedStories.add(item.key);
4050
- }
4051
- if (loopResult.halted) {
4052
- halted = true;
4053
- }
4054
- if (state.phase === "max-iterations" || state.phase === "circuit-breaker") {
4055
3934
  halted = true;
3935
+ break;
4056
3936
  }
4057
- continue;
4058
- }
4059
- const taskName = step;
4060
- const task = config.workflow.tasks[taskName];
4061
- if (!task) {
4062
- warn(`workflow-engine: task "${taskName}" not found in workflow tasks, skipping`);
4063
- continue;
4064
- }
4065
- if (task.agent === null) {
4066
- if (task.scope === "per-run") {
4067
- if (isTaskCompleted(state, taskName, PER_RUN_SENTINEL)) {
4068
- warn(`workflow-engine: skipping completed task ${taskName} for ${PER_RUN_SENTINEL}`);
4069
- continue;
4070
- }
4071
- try {
4072
- const nullResult = await executeNullTask(
4073
- task,
4074
- taskName,
4075
- PER_RUN_SENTINEL,
4076
- state,
4077
- config,
4078
- lastOutputContract ?? void 0,
4079
- accumulatedCostUsd
4080
- );
4081
- state = nullResult.updatedState;
4082
- lastOutputContract = nullResult.contract;
4083
- tasksCompleted++;
4084
- } catch (err) {
4085
- const engineError = isEngineError(err) ? err : handleDispatchError(err, taskName, PER_RUN_SENTINEL);
4086
- errors.push(engineError);
4087
- state = recordErrorInState(state, taskName, PER_RUN_SENTINEL, engineError);
4088
- writeWorkflowState(state, projectDir);
4089
- }
4090
- } else {
4091
- for (const item of workItems) {
4092
- processedStories.add(item.key);
4093
- if (isTaskCompleted(state, taskName, item.key)) {
4094
- warn(`workflow-engine: skipping completed task ${taskName} for ${item.key}`);
4095
- continue;
4096
- }
4097
- try {
4098
- const nullResult = await executeNullTask(
4099
- task,
4100
- taskName,
4101
- item.key,
4102
- state,
4103
- config,
4104
- lastOutputContract ?? void 0,
4105
- accumulatedCostUsd
4106
- );
4107
- state = nullResult.updatedState;
4108
- lastOutputContract = nullResult.contract;
4109
- tasksCompleted++;
4110
- } catch (err) {
4111
- const engineError = isEngineError(err) ? err : handleDispatchError(err, taskName, item.key);
4112
- errors.push(engineError);
4113
- state = recordErrorInState(state, taskName, item.key, engineError);
4114
- writeWorkflowState(state, projectDir);
4115
- }
4116
- }
4117
- }
4118
- continue;
4119
- }
4120
- const definition = config.agents[task.agent];
4121
- if (!definition) {
4122
- warn(`workflow-engine: agent "${task.agent}" not found for task "${taskName}", skipping`);
4123
- continue;
4124
- }
4125
- if (task.scope === "per-run") {
4126
- if (isTaskCompleted(state, taskName, PER_RUN_SENTINEL)) {
4127
- warn(`workflow-engine: skipping completed task ${taskName} for ${PER_RUN_SENTINEL}`);
4128
- continue;
4129
- }
4130
- try {
4131
- const dispatchResult = await dispatchTaskWithResult(
4132
- task,
4133
- taskName,
4134
- PER_RUN_SENTINEL,
4135
- definition,
4136
- state,
4137
- config,
4138
- void 0,
4139
- lastOutputContract ?? void 0
4140
- );
4141
- state = dispatchResult.updatedState;
4142
- lastOutputContract = dispatchResult.contract;
4143
- propagateVerifyFlags(taskName, dispatchResult.contract, projectDir);
4144
- accumulatedCostUsd += dispatchResult.contract?.cost_usd ?? 0;
4145
- tasksCompleted++;
4146
- } catch (err) {
4147
- const engineError = handleDispatchError(err, taskName, PER_RUN_SENTINEL);
4148
- errors.push(engineError);
4149
- if (config.onEvent) {
4150
- config.onEvent({ type: "dispatch-error", taskName, storyKey: PER_RUN_SENTINEL, error: { code: engineError.code, message: engineError.message } });
4151
- } else {
4152
- warn(`[${taskName}] ${PER_RUN_SENTINEL} \u2014 ERROR: [${engineError.code}] ${engineError.message}`);
4153
- }
4154
- state = recordErrorInState(state, taskName, PER_RUN_SENTINEL, engineError);
4155
- writeWorkflowState(state, projectDir);
4156
- if (err instanceof DispatchError && HALT_ERROR_CODES.has(err.code)) {
4157
- halted = true;
4158
- }
4159
- }
4160
- } else {
4161
- for (const item of workItems) {
4162
- if (config.abortSignal?.aborted) {
3937
+ processedStories.add(item.key);
3938
+ for (const storyStep of config.workflow.storyFlow) {
3939
+ if (halted || config.abortSignal?.aborted) {
4163
3940
  halted = true;
4164
3941
  break;
4165
3942
  }
4166
- processedStories.add(item.key);
4167
- if (isTaskCompleted(state, taskName, item.key)) {
4168
- warn(`workflow-engine: skipping completed task ${taskName} for ${item.key}`);
3943
+ if (typeof storyStep !== "string") continue;
3944
+ const taskName2 = storyStep;
3945
+ const task2 = config.workflow.tasks[taskName2];
3946
+ if (!task2) {
3947
+ warn(`workflow-engine: task "${taskName2}" not found, skipping`);
4169
3948
  continue;
4170
3949
  }
3950
+ if (task2.agent === null) continue;
3951
+ const definition2 = config.agents[task2.agent];
3952
+ if (!definition2) {
3953
+ warn(`workflow-engine: agent "${task2.agent}" not found for "${taskName2}"`);
3954
+ continue;
3955
+ }
3956
+ if (isTaskCompleted(state, taskName2, item.key)) continue;
4171
3957
  try {
4172
- const dispatchResult = await dispatchTaskWithResult(
4173
- task,
4174
- taskName,
4175
- item.key,
4176
- definition,
4177
- state,
4178
- config,
4179
- void 0,
4180
- lastOutputContract ?? void 0
4181
- );
4182
- state = dispatchResult.updatedState;
4183
- lastOutputContract = dispatchResult.contract;
4184
- propagateVerifyFlags(taskName, dispatchResult.contract, projectDir);
4185
- accumulatedCostUsd += dispatchResult.contract?.cost_usd ?? 0;
3958
+ const dr = await dispatchTaskWithResult(task2, taskName2, item.key, definition2, state, config, void 0, lastOutputContract ?? void 0);
3959
+ state = dr.updatedState;
3960
+ lastOutputContract = dr.contract;
3961
+ propagateVerifyFlags(taskName2, dr.contract, projectDir);
3962
+ accumulatedCostUsd += dr.contract?.cost_usd ?? 0;
4186
3963
  tasksCompleted++;
4187
3964
  } catch (err) {
4188
- const engineError = handleDispatchError(err, taskName, item.key);
3965
+ const engineError = handleDispatchError(err, taskName2, item.key);
4189
3966
  errors.push(engineError);
4190
3967
  if (config.onEvent) {
4191
- config.onEvent({ type: "dispatch-error", taskName, storyKey: item.key, error: { code: engineError.code, message: engineError.message } });
3968
+ config.onEvent({ type: "dispatch-error", taskName: taskName2, storyKey: item.key, error: { code: engineError.code, message: engineError.message } });
4192
3969
  } else {
4193
- warn(`[${taskName}] ${item.key} \u2014 ERROR: [${engineError.code}] ${engineError.message}`);
3970
+ warn(`[${taskName2}] ${item.key} \u2014 ERROR: [${engineError.code}] ${engineError.message}`);
4194
3971
  }
4195
- state = recordErrorInState(state, taskName, item.key, engineError);
3972
+ state = recordErrorInState(state, taskName2, item.key, engineError);
4196
3973
  writeWorkflowState(state, projectDir);
4197
3974
  if (err instanceof DispatchError && HALT_ERROR_CODES.has(err.code)) {
4198
3975
  halted = true;
4199
- break;
4200
3976
  }
4201
- continue;
3977
+ break;
4202
3978
  }
4203
3979
  }
4204
3980
  }
3981
+ continue;
3982
+ }
3983
+ if (isLoopBlock(step)) {
3984
+ const loopResult = await executeLoopBlock(step, state, config, workItems, lastOutputContract, storyFlowTasks);
3985
+ state = loopResult.state;
3986
+ errors.push(...loopResult.errors);
3987
+ tasksCompleted += loopResult.tasksCompleted;
3988
+ lastOutputContract = loopResult.lastContract;
3989
+ for (const item of workItems) processedStories.add(item.key);
3990
+ if (loopResult.halted || state.phase === "max-iterations" || state.phase === "circuit-breaker") {
3991
+ halted = true;
3992
+ }
3993
+ continue;
3994
+ }
3995
+ const taskName = step;
3996
+ const task = config.workflow.tasks[taskName];
3997
+ if (!task) {
3998
+ warn(`workflow-engine: task "${taskName}" not found, skipping`);
3999
+ continue;
4000
+ }
4001
+ if (task.agent === null) continue;
4002
+ const definition = config.agents[task.agent];
4003
+ if (!definition) {
4004
+ warn(`workflow-engine: agent "${task.agent}" not found for "${taskName}"`);
4005
+ continue;
4205
4006
  }
4007
+ if (isTaskCompleted(state, taskName, PER_RUN_SENTINEL)) continue;
4008
+ try {
4009
+ const dr = await dispatchTaskWithResult(task, taskName, PER_RUN_SENTINEL, definition, state, config, void 0, lastOutputContract ?? void 0);
4010
+ state = dr.updatedState;
4011
+ lastOutputContract = dr.contract;
4012
+ propagateVerifyFlags(taskName, dr.contract, projectDir);
4013
+ accumulatedCostUsd += dr.contract?.cost_usd ?? 0;
4014
+ tasksCompleted++;
4015
+ } catch (err) {
4016
+ const engineError = handleDispatchError(err, taskName, PER_RUN_SENTINEL);
4017
+ errors.push(engineError);
4018
+ if (config.onEvent) {
4019
+ config.onEvent({ type: "dispatch-error", taskName, storyKey: PER_RUN_SENTINEL, error: { code: engineError.code, message: engineError.message } });
4020
+ } else {
4021
+ warn(`[${taskName}] ${PER_RUN_SENTINEL} \u2014 ERROR: [${engineError.code}] ${engineError.message}`);
4022
+ }
4023
+ state = recordErrorInState(state, taskName, PER_RUN_SENTINEL, engineError);
4024
+ writeWorkflowState(state, projectDir);
4025
+ if (err instanceof DispatchError && HALT_ERROR_CODES.has(err.code)) {
4026
+ halted = true;
4027
+ }
4028
+ }
4029
+ }
4206
4030
  if (state.phase === "interrupted") {
4207
4031
  } else if (errors.length === 0 && state.phase !== "max-iterations" && state.phase !== "circuit-breaker") {
4208
4032
  state = { ...state, phase: "completed" };
@@ -5217,9 +5041,13 @@ function WorkflowGraph({ flow, currentTask, taskStates, taskMeta }) {
5217
5041
  if (isLoopBlock2(step)) break;
5218
5042
  if (typeof step === "string") {
5219
5043
  if (elements2.length > 0) elements2.push(/* @__PURE__ */ jsx2(Text2, { children: " \u2192 " }, `a-${step}`));
5220
- elements2.push(
5221
- /* @__PURE__ */ jsx2(TaskNode, { name: step, status: taskStates[step], spinnerFrame, driver: meta[step]?.driver }, `t-${step}`)
5222
- );
5044
+ if (step === "story_flow") {
5045
+ elements2.push(/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "stories \u2713" }, "sf"));
5046
+ } else {
5047
+ elements2.push(
5048
+ /* @__PURE__ */ jsx2(TaskNode, { name: step, status: taskStates[step], spinnerFrame, driver: meta[step]?.driver }, `t-${step}`)
5049
+ );
5050
+ }
5223
5051
  }
5224
5052
  }
5225
5053
  if (elements2.length > 0) elements2.push(/* @__PURE__ */ jsx2(Text2, { children: " \u2192 " }, "loop-arrow"));
@@ -5246,9 +5074,13 @@ function WorkflowGraph({ flow, currentTask, taskStates, taskMeta }) {
5246
5074
  if (elements.length > 0) {
5247
5075
  elements.push(/* @__PURE__ */ jsx2(Text2, { children: " \u2192 " }, `a-${step}`));
5248
5076
  }
5249
- elements.push(
5250
- /* @__PURE__ */ jsx2(TaskNode, { name: step, status: taskStates[step], spinnerFrame, driver: meta[step]?.driver }, `t-${step}`)
5251
- );
5077
+ if (step === "story_flow") {
5078
+ elements.push(/* @__PURE__ */ jsx2(Text2, { color: "cyan", children: "stories \u2713" }, "sf"));
5079
+ } else {
5080
+ elements.push(
5081
+ /* @__PURE__ */ jsx2(TaskNode, { name: step, status: taskStates[step], spinnerFrame, driver: meta[step]?.driver }, `t-${step}`)
5082
+ );
5083
+ }
5252
5084
  }
5253
5085
  return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: /* @__PURE__ */ jsxs2(Text2, { children: [
5254
5086
  " ",
@@ -5551,7 +5383,8 @@ function App({ state, onCycleLane, onQuit }) {
5551
5383
  }, { isActive: typeof process.stdin.setRawMode === "function" });
5552
5384
  const activeLaneCount = state.laneCount ?? 0;
5553
5385
  const termRows = process.stdout.rows || 24;
5554
- const fixedHeight = 10;
5386
+ const staticLines = state.messages.length;
5387
+ const fixedHeight = 10 + staticLines + 2;
5555
5388
  const availableHeight = Math.max(3, termRows - fixedHeight);
5556
5389
  return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
5557
5390
  /* @__PURE__ */ jsx7(Static, { items: state.messages, children: (msg, i) => /* @__PURE__ */ jsx7(StoryMessageLine, { msg }, i) }),
@@ -6287,32 +6120,25 @@ function registerRunCommand(program) {
6287
6120
  epicData[epicId] = { storiesDone: epic.storiesDone ?? 0, storiesTotal: epic.storiesTotal ?? 0 };
6288
6121
  }
6289
6122
  }
6290
- const preLoopTasks = /* @__PURE__ */ new Set();
6291
- const loopTasks = /* @__PURE__ */ new Set();
6292
- for (const step of parsedWorkflow.flow) {
6293
- if (typeof step === "string") {
6294
- preLoopTasks.add(step);
6295
- } else if (typeof step === "object" && "loop" in step) {
6296
- for (const lt of step.loop) loopTasks.add(lt);
6297
- }
6123
+ const storyFlowTasks = /* @__PURE__ */ new Set();
6124
+ for (const step of parsedWorkflow.storyFlow) {
6125
+ if (typeof step === "string") storyFlowTasks.add(step);
6298
6126
  }
6299
- let pastLoop = false;
6300
- for (const step of parsedWorkflow.flow) {
6127
+ const epicLoopTasks = /* @__PURE__ */ new Set();
6128
+ for (const step of parsedWorkflow.epicFlow) {
6301
6129
  if (typeof step === "object" && "loop" in step) {
6302
- pastLoop = true;
6303
- continue;
6130
+ for (const lt of step.loop) epicLoopTasks.add(lt);
6304
6131
  }
6305
- if (pastLoop && typeof step === "string") preLoopTasks.add(step);
6306
6132
  }
6307
- let inLoop = false;
6133
+ let inEpicPhase = false;
6308
6134
  const taskStates = {};
6309
6135
  const taskMeta = {};
6310
6136
  for (const [tn, task] of Object.entries(parsedWorkflow.tasks)) {
6311
6137
  taskStates[tn] = "pending";
6312
- if (loopTasks.has(tn)) taskStates[`loop:${tn}`] = "pending";
6138
+ if (epicLoopTasks.has(tn)) taskStates[`loop:${tn}`] = "pending";
6313
6139
  const driverLabel2 = task.model ?? task.driver ?? "claude-code";
6314
6140
  taskMeta[tn] = { driver: driverLabel2 };
6315
- if (loopTasks.has(tn)) taskMeta[`loop:${tn}`] = { driver: driverLabel2 };
6141
+ if (epicLoopTasks.has(tn)) taskMeta[`loop:${tn}`] = { driver: driverLabel2 };
6316
6142
  }
6317
6143
  const storyEntries = [];
6318
6144
  for (const [key, status] of Object.entries(statuses)) {
@@ -6323,24 +6149,27 @@ function registerRunCommand(program) {
6323
6149
  else if (status === "failed") storyEntries.push({ key, status: "failed" });
6324
6150
  }
6325
6151
  renderer.updateStories(storyEntries);
6326
- renderer.updateWorkflowState(parsedWorkflow.flow, null, { ...taskStates }, { ...taskMeta });
6152
+ renderer.updateWorkflowState(parsedWorkflow.storyFlow, null, { ...taskStates }, { ...taskMeta });
6327
6153
  const onEvent = (event) => {
6328
6154
  if (event.type === "stream-event" && event.streamEvent) {
6329
6155
  renderer.update(event.streamEvent, event.driverName);
6330
6156
  }
6331
6157
  if (event.type === "dispatch-start") {
6332
- if (event.storyKey !== currentStoryKey && preLoopTasks.has(event.taskName)) {
6333
- inLoop = false;
6334
- for (const tn of Object.keys(taskStates)) {
6158
+ const isStoryTask = storyFlowTasks.has(event.taskName);
6159
+ const isEpicTask = !isStoryTask;
6160
+ if (isStoryTask && event.storyKey !== currentStoryKey) {
6161
+ inEpicPhase = false;
6162
+ for (const tn of storyFlowTasks) {
6335
6163
  taskStates[tn] = "pending";
6336
6164
  }
6337
6165
  }
6166
+ if (isEpicTask && !inEpicPhase) {
6167
+ inEpicPhase = true;
6168
+ }
6338
6169
  currentStoryKey = event.storyKey;
6339
6170
  currentTaskName = event.taskName;
6340
- if (loopTasks.has(event.taskName) && taskStates[event.taskName] === "done") {
6341
- inLoop = true;
6342
- }
6343
- const stateKey = inLoop && loopTasks.has(event.taskName) ? `loop:${event.taskName}` : event.taskName;
6171
+ const inLoop = inEpicPhase && epicLoopTasks.has(event.taskName) && taskStates[event.taskName] === "done";
6172
+ const stateKey = inLoop ? `loop:${event.taskName}` : event.taskName;
6344
6173
  const epicId = extractEpicId2(event.storyKey);
6345
6174
  const epic = epicData[epicId];
6346
6175
  renderer.updateSprintState({
@@ -6355,33 +6184,29 @@ function registerRunCommand(program) {
6355
6184
  epicStoriesTotal: epic?.storiesTotal ?? 0
6356
6185
  });
6357
6186
  taskStates[stateKey] = "active";
6358
- renderer.updateWorkflowState(parsedWorkflow.flow, event.taskName, { ...taskStates }, { ...taskMeta });
6359
- updateStoryStatus2(event.storyKey, "in-progress");
6360
- const idx = storyEntries.findIndex((s) => s.key === event.storyKey);
6361
- if (idx >= 0 && storyEntries[idx].status === "pending") {
6362
- storyEntries[idx] = { ...storyEntries[idx], status: "in-progress" };
6363
- renderer.updateStories([...storyEntries]);
6187
+ const displayFlow = inEpicPhase ? parsedWorkflow.epicFlow : parsedWorkflow.storyFlow;
6188
+ renderer.updateWorkflowState(displayFlow, event.taskName, { ...taskStates }, { ...taskMeta });
6189
+ if (isStoryTask) {
6190
+ updateStoryStatus2(event.storyKey, "in-progress");
6191
+ const idx = storyEntries.findIndex((s) => s.key === event.storyKey);
6192
+ if (idx >= 0 && storyEntries[idx].status === "pending") {
6193
+ storyEntries[idx] = { ...storyEntries[idx], status: "in-progress" };
6194
+ renderer.updateStories([...storyEntries]);
6195
+ }
6364
6196
  }
6365
6197
  }
6366
6198
  if (event.type === "dispatch-end") {
6367
6199
  totalCostUsd += event.costUsd ?? 0;
6368
- const stateKey = inLoop && loopTasks.has(event.taskName) ? `loop:${event.taskName}` : event.taskName;
6200
+ const inLoop = inEpicPhase && epicLoopTasks.has(event.taskName) && taskStates[event.taskName] === "done";
6201
+ const stateKey = inLoop ? `loop:${event.taskName}` : event.taskName;
6369
6202
  taskStates[stateKey] = "done";
6370
6203
  taskMeta[stateKey] = {
6371
6204
  ...taskMeta[stateKey],
6372
6205
  costUsd: (taskMeta[stateKey]?.costUsd ?? 0) + (event.costUsd ?? 0),
6373
6206
  elapsedMs: (taskMeta[stateKey]?.elapsedMs ?? 0) + (event.elapsedMs ?? 0)
6374
6207
  };
6375
- renderer.updateWorkflowState(parsedWorkflow.flow, event.taskName, { ...taskStates }, { ...taskMeta });
6376
- if (event.taskName === "verify") {
6377
- storiesDone++;
6378
- updateStoryStatus2(event.storyKey, "done");
6379
- const idx = storyEntries.findIndex((s) => s.key === event.storyKey);
6380
- if (idx >= 0) {
6381
- storyEntries[idx] = { ...storyEntries[idx], status: "done" };
6382
- renderer.updateStories([...storyEntries]);
6383
- }
6384
- }
6208
+ const displayFlow = inEpicPhase ? parsedWorkflow.epicFlow : parsedWorkflow.storyFlow;
6209
+ renderer.updateWorkflowState(displayFlow, event.taskName, { ...taskStates }, { ...taskMeta });
6385
6210
  renderer.updateSprintState({
6386
6211
  storyKey: event.storyKey,
6387
6212
  phase: event.taskName,
@@ -6389,11 +6214,25 @@ function registerRunCommand(program) {
6389
6214
  total: counts.total,
6390
6215
  totalCost: totalCostUsd
6391
6216
  });
6217
+ if (storyFlowTasks.has(event.taskName)) {
6218
+ const allStoryDone = [...storyFlowTasks].every((tn) => taskStates[tn] === "done");
6219
+ if (allStoryDone) {
6220
+ storiesDone++;
6221
+ updateStoryStatus2(event.storyKey, "done");
6222
+ const idx = storyEntries.findIndex((s) => s.key === event.storyKey);
6223
+ if (idx >= 0) {
6224
+ storyEntries[idx] = { ...storyEntries[idx], status: "done" };
6225
+ renderer.updateStories([...storyEntries]);
6226
+ }
6227
+ }
6228
+ }
6392
6229
  }
6393
6230
  if (event.type === "dispatch-error") {
6394
- const stateKey = inLoop && loopTasks.has(event.taskName) ? `loop:${event.taskName}` : event.taskName;
6231
+ const inLoop = inEpicPhase && epicLoopTasks.has(event.taskName);
6232
+ const stateKey = inLoop ? `loop:${event.taskName}` : event.taskName;
6395
6233
  taskStates[stateKey] = "failed";
6396
- renderer.updateWorkflowState(parsedWorkflow.flow, event.taskName, { ...taskStates }, { ...taskMeta });
6234
+ const displayFlow = inEpicPhase ? parsedWorkflow.epicFlow : parsedWorkflow.storyFlow;
6235
+ renderer.updateWorkflowState(displayFlow, event.taskName, { ...taskStates }, { ...taskMeta });
6397
6236
  renderer.addMessage({
6398
6237
  type: "fail",
6399
6238
  key: event.storyKey,
@@ -6415,7 +6254,6 @@ function registerRunCommand(program) {
6415
6254
  runId: `run-${Date.now()}`,
6416
6255
  projectDir,
6417
6256
  abortSignal: abortController.signal,
6418
- storyPipeline: true,
6419
6257
  maxIterations,
6420
6258
  onEvent
6421
6259
  };
@@ -11297,7 +11135,7 @@ function registerTeardownCommand(program) {
11297
11135
  } else if (otlpMode === "remote-routed") {
11298
11136
  if (!options.keepDocker) {
11299
11137
  try {
11300
- const { stopCollectorOnly: stopCollectorOnly2 } = await import("./docker-RHFGKHJV.js");
11138
+ const { stopCollectorOnly: stopCollectorOnly2 } = await import("./docker-R4444RHQ.js");
11301
11139
  stopCollectorOnly2();
11302
11140
  result.docker.stopped = true;
11303
11141
  if (!isJson) {
@@ -11329,7 +11167,7 @@ function registerTeardownCommand(program) {
11329
11167
  info("Shared stack: kept running (other projects may use it)");
11330
11168
  }
11331
11169
  } else if (isLegacyStack) {
11332
- const { isStackRunning: isStackRunning2, stopStack } = await import("./docker-RHFGKHJV.js");
11170
+ const { isStackRunning: isStackRunning2, stopStack } = await import("./docker-R4444RHQ.js");
11333
11171
  let stackRunning = false;
11334
11172
  try {
11335
11173
  stackRunning = isStackRunning2(composeFile);
@@ -14316,7 +14154,7 @@ function registerDriversCommand(program) {
14316
14154
  }
14317
14155
 
14318
14156
  // src/index.ts
14319
- var VERSION = true ? "0.31.6" : "0.0.0-dev";
14157
+ var VERSION = true ? "0.32.0" : "0.0.0-dev";
14320
14158
  function createProgram() {
14321
14159
  const program = new Command();
14322
14160
  program.name("codeharness").description("Makes autonomous coding agents produce software that actually works").version(VERSION).option("--json", "Output in machine-readable JSON format");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharness",
3
- "version": "0.31.6",
3
+ "version": "0.32.0",
4
4
  "type": "module",
5
5
  "description": "CLI for codeharness — makes autonomous coding agents produce software that actually works",
6
6
  "bin": {
@@ -1,52 +1,48 @@
1
1
  tasks:
2
2
  create-story:
3
3
  agent: story-creator
4
- scope: per-story
5
4
  session: fresh
6
5
  source_access: true
7
6
  model: claude-opus-4-6
8
7
  implement:
9
8
  agent: dev
10
- scope: per-story
11
9
  session: fresh
12
10
  source_access: true
13
11
  model: claude-sonnet-4-6
14
12
  check:
15
13
  agent: checker
16
- scope: per-story
17
14
  session: fresh
18
15
  source_access: true
19
16
  driver: codex
20
17
  review:
21
18
  agent: reviewer
22
- scope: per-story
23
19
  session: fresh
24
20
  source_access: true
25
21
  driver: codex
26
22
  verify:
27
23
  agent: evaluator
28
- scope: per-story
29
24
  session: fresh
30
25
  source_access: false
31
26
  driver: codex
32
27
  retry:
33
28
  agent: dev
34
- scope: per-story
35
29
  session: fresh
36
30
  source_access: true
37
31
  model: claude-sonnet-4-6
38
32
  retro:
39
33
  agent: retro
40
- scope: per-epic
41
34
  session: fresh
42
35
  source_access: true
43
36
  model: claude-opus-4-6
44
37
 
45
- flow:
38
+ story_flow:
46
39
  - create-story
47
40
  - implement
48
41
  - check
49
42
  - review
43
+
44
+ epic_flow:
45
+ - story_flow
50
46
  - verify
51
47
  - loop:
52
48
  - retry