Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bd00ab8
chore: initialize overstory and ecosystem tools
ldangelo Mar 30, 2026
bcd1e3c
fix: eliminate empty commits from jj and switch refinery to squash merge
ldangelo Mar 30, 2026
5687eec
docs: add PRD-2026-007 Epic Execution Mode
ldangelo Mar 30, 2026
3a2e155
docs: add TRD-2026-007 Epic Execution Mode
ldangelo Mar 30, 2026
334a1b3
scaffold: TRD-2026-007 epic execution mode beads hierarchy (30 tasks)
ldangelo Mar 30, 2026
15b7912
feat(epic-mode): implement TRD-001 through TRD-004 — epic workflow fo…
ldangelo Mar 30, 2026
f8513b0
feat(epic-mode): TRD-005 + TRD-005-TEST — outer task loop in executeP…
ldangelo Mar 30, 2026
0ebbc9b
mulch: update expertise
ldangelo Mar 30, 2026
20ea4e6
feat(epic-mode): TRD-008 through TRD-015 — epic infrastructure comple…
ldangelo Mar 30, 2026
3e57311
feat(epic-mode): TRD-006 + TRD-009 — epic dispatch and resume detection
ldangelo Mar 30, 2026
7e13a82
docs(TRD): sync checkboxes to bead closure state — 28/30 tasks complete
ldangelo Mar 30, 2026
a9d0ad0
fix: update tests for epic workflow addition
ldangelo Mar 30, 2026
4ad7b97
fix: await writeSessionLogSafe to prevent tmpdir cleanup race
ldangelo Mar 30, 2026
45c0b2f
docs(TRD): create TRD-2026-006 Multi-Project Native Task Management
ldangelo Mar 30, 2026
71f04d2
chore(beads): sync closed bd-zcyl beads to JSONL
ldangelo Mar 30, 2026
3b34e77
Merge branch 'dev' into feature/trd-2026-007-epic-execution-mode
ldangelo Mar 30, 2026
77eefbc
Merge branch 'dev' into feature/trd-2026-007-epic-execution-mode
ldangelo Mar 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .mulch/expertise/epic-execution.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"type":"reference","name":"epic-resume-detection","description":"TRD-009: parseCompletedTaskIds() in pipeline-executor.ts parses git log --oneline matching trailing (beadId) pattern. detectCompletedTasks() wraps with error handling. Resume logic filters epicTasks before the task loop.","classification":"tactical","recorded_at":"2026-03-30T14:49:04.542Z","id":"mx-6b2456"}
Empty file.
4 changes: 3 additions & 1 deletion .mulch/mulch.config.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
version: '1'
domains: []
domains:
- epic-execution
- pipeline-executor
governance:
max_entries: 100
warn_entries: 150
Expand Down
48 changes: 24 additions & 24 deletions docs/TRD/TRD-2026-007-epic-execution-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,36 +268,36 @@ Dispatcher
## Sprint Planning

### Sprint 1: Core Epic Runner (~20h)
- [ ] **TRD-001** (2h): Epic workflow YAML fields
- [ ] **TRD-001-TEST** (1h): Tests for YAML parsing
- [ ] **TRD-002** (1h): Bundled epic.yaml
- [ ] **TRD-003** (2h): Task ordering module
- [ ] **TRD-003-TEST** (1h): Tests for task ordering
- [ ] **TRD-004** (1h): Epic fields in PipelineContext
- [ ] **TRD-005** (4h): Outer task loop in executePipeline [CRITICAL PATH]
- [ ] **TRD-005-TEST** (3h): Integration tests for task loop
- [ ] **TRD-006** (3h): Dispatcher epic detection
- [ ] **TRD-006-TEST** (2h): Tests for epic dispatch
- [x] **TRD-001** (2h): Epic workflow YAML fields
- [x] **TRD-001-TEST** (1h): Tests for YAML parsing
- [x] **TRD-002** (1h): Bundled epic.yaml
- [x] **TRD-003** (2h): Task ordering module
- [x] **TRD-003-TEST** (1h): Tests for task ordering
- [x] **TRD-004** (1h): Epic fields in PipelineContext
- [x] **TRD-005** (4h): Outer task loop in executePipeline [CRITICAL PATH]
- [x] **TRD-005-TEST** (3h): Integration tests for task loop
- [x] **TRD-006** (3h): Dispatcher epic detection
- [x] **TRD-006-TEST** (2h): Tests for epic dispatch

### Sprint 2: Session, Finalize, Resume (~13h)
- [ ] **TRD-007** (3h): Session reuse [depends: TRD-005]
- [ ] **TRD-007-TEST** (2h): Tests for session reuse
- [ ] **TRD-008** (2h): Single finalize [depends: TRD-005]
- [ ] **TRD-008-TEST** (1h): Tests for finalize
- [ ] **TRD-009** (3h): Resume from last task [depends: TRD-005]
- [ ] **TRD-009-TEST** (2h): Tests for resume
- [x] **TRD-008** (2h): Single finalize [depends: TRD-005]
- [x] **TRD-008-TEST** (1h): Tests for finalize
- [x] **TRD-009** (3h): Resume from last task [depends: TRD-005]
- [x] **TRD-009-TEST** (2h): Tests for resume

### Sprint 3: Observability and Polish (~11h)
- [ ] **TRD-010** (1h): Bug bead creation [depends: TRD-005]
- [ ] **TRD-010-TEST** (1h): Tests for bug beads
- [ ] **TRD-011** (1h): Per-task bead status [depends: TRD-005]
- [ ] **TRD-011-TEST** (1h): Tests for bead status
- [ ] **TRD-012** (2h): Epic progress display [depends: TRD-005]
- [ ] **TRD-012-TEST** (1h): Tests for status display
- [ ] **TRD-013** (1h): onError for epics [depends: TRD-005]
- [ ] **TRD-013-TEST** (1h): Tests for onError
- [ ] **TRD-014** (1h): Workflow override [depends: TRD-001]
- [ ] **TRD-015** (1h): Task timeout [depends: TRD-005]
- [x] **TRD-010** (1h): Bug bead creation [depends: TRD-005]
- [x] **TRD-010-TEST** (1h): Tests for bug beads
- [x] **TRD-011** (1h): Per-task bead status [depends: TRD-005]
- [x] **TRD-011-TEST** (1h): Tests for bead status
- [x] **TRD-012** (2h): Epic progress display [depends: TRD-005]
- [x] **TRD-012-TEST** (1h): Tests for status display
- [x] **TRD-013** (1h): onError for epics [depends: TRD-005]
- [x] **TRD-013-TEST** (1h): Tests for onError
- [x] **TRD-014** (1h): Workflow override [depends: TRD-001]
- [x] **TRD-015** (1h): Task timeout [depends: TRD-005]

**Total: ~44h estimated across 30 tasks (15 implementation + 15 test)**

Expand Down
83 changes: 83 additions & 0 deletions src/defaults/workflows/epic.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Epic workflow: Developer ⇄ QA per task, then Finalize once at the end.
#
# Used when a bead of type "epic" is dispatched. The pipeline executor
# iterates child tasks in dependency order, running taskPhases for each.
# After all tasks pass, finalPhases execute once to rebase, test, and push.
#
# Models map keys: "default" (required), "P0"–"P4" (optional priority overrides).
# Priority P0 = critical, P4 = backlog. Shorthands: haiku, sonnet, opus.
name: epic
onError: stop
# Per-task timeout in seconds. If a task's phases exceed this, the task fails.
# Default: 300 (5 minutes). Set to 0 to disable.
taskTimeout: 300

setup:
- command: npm install --prefer-offline --no-audit
description: Install Node.js dependencies
failFatal: true
setupCache:
key: package-lock.json
path: node_modules

# Per-task phases: run for each child task in the epic
taskPhases:
- developer
- qa

# Final phases: run once after all tasks complete
finalPhases:
- finalize

phases:
- name: developer
prompt: developer.md
models:
default: sonnet
P0: opus
maxTurns: 80
artifact: DEVELOPER_REPORT.md
mail:
onStart: true
onComplete: true
files:
reserve: true
leaseSecs: 600

- name: qa
prompt: qa.md
models:
default: sonnet
P0: opus
maxTurns: 30
artifact: QA_REPORT.md
verdict: true
retryWith: developer
retryOnFail: 2
mail:
onStart: true
onComplete: true
onFail: developer

- name: finalize
prompt: finalize.md
models:
default: haiku
maxTurns: 30
artifact: FINALIZE_VALIDATION.md
verdict: true
retryWith: developer
retryOnFail: 1
mail:
onStart: true
onComplete: true
onFail: developer

onFailure:
name: troubleshooter
prompt: troubleshooter.md
models:
default: sonnet
P0: opus
maxTurns: 20
artifact: TROUBLESHOOT_REPORT.md
5 changes: 5 additions & 0 deletions src/lib/__tests__/beads-rust-deprecation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ const BEADS_RUST_KNOWN_VIOLATIONS: Record<string, string> = {
// Needs: change all parameter types from BeadsRustClient to ITaskClient.
"orchestrator/sling-executor.ts":
"TRD-014: function parameter types → ITaskClient",

// Epic task ordering imports BeadsRustClient for bead detail queries.
// Needs: change parameter type from BeadsRustClient to ITaskClient.
"orchestrator/task-ordering.ts":
"TRD-2026-007: parameter type → ITaskClient",
};

/** Test files (in __tests__/, or *.test.ts / *.spec.ts) are always exempt. */
Expand Down
91 changes: 91 additions & 0 deletions src/lib/__tests__/workflow-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,10 @@ describe("resolveWorkflowName", () => {
expect(resolveWorkflowName("smoke")).toBe("smoke");
});

it("returns 'epic' for epic bead type", () => {
expect(resolveWorkflowName("epic")).toBe("epic");
});

it("returns 'default' for feature bead type", () => {
expect(resolveWorkflowName("feature")).toBe("default");
});
Expand Down Expand Up @@ -652,3 +656,90 @@ describe("validateWorkflowConfig — vcs block", () => {
).toThrow(/vcs.backend must be/);
});
});

// ── validateWorkflowConfig — epic mode (taskPhases, finalPhases) ────────────

describe("validateWorkflowConfig — epic mode", () => {
const epicConfig = {
name: "epic",
phases: [
{ name: "developer", prompt: "developer.md" },
{ name: "qa", prompt: "qa.md", verdict: true, retryWith: "developer", retryOnFail: 2 },
{ name: "finalize", prompt: "finalize.md" },
],
};

it("parses taskPhases and finalPhases from YAML", () => {
const raw = {
...epicConfig,
taskPhases: ["developer", "qa"],
finalPhases: ["finalize"],
};
const config = validateWorkflowConfig(raw, "epic");
expect(config.taskPhases).toEqual(["developer", "qa"]);
expect(config.finalPhases).toEqual(["finalize"]);
});

it("leaves taskPhases and finalPhases undefined when absent (single-task mode)", () => {
const config = validateWorkflowConfig(epicConfig, "default");
expect(config.taskPhases).toBeUndefined();
expect(config.finalPhases).toBeUndefined();
});

it("throws on non-array taskPhases", () => {
const raw = { ...epicConfig, taskPhases: "developer" };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/taskPhases.*must be an array/,
);
});

it("throws on non-array finalPhases", () => {
const raw = { ...epicConfig, finalPhases: "finalize" };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/finalPhases.*must be an array/,
);
});

it("throws when taskPhases references a phase not in phases array", () => {
const raw = { ...epicConfig, taskPhases: ["developer", "explorer"] };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/references phase 'explorer' which is not defined/,
);
});

it("throws when finalPhases references a phase not in phases array", () => {
const raw = { ...epicConfig, finalPhases: ["nonexistent"] };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/references phase 'nonexistent' which is not defined/,
);
});

it("throws on non-string entry in taskPhases", () => {
const raw = { ...epicConfig, taskPhases: ["developer", 42] };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/taskPhases\[1\] must be a non-empty string/,
);
});

it("throws on empty string entry in taskPhases", () => {
const raw = { ...epicConfig, taskPhases: ["developer", ""] };
expect(() => validateWorkflowConfig(raw, "epic")).toThrow(
/taskPhases\[1\] must be a non-empty string/,
);
});

it("bundled epic.yaml loads with taskPhases and finalPhases", () => {
const tmpDir2 = tmpdir() + `/wl-epic-test-${Date.now()}`;
mkdirSync(tmpDir2, { recursive: true });
const config = loadWorkflowConfig("epic", tmpDir2);
rmSync(tmpDir2, { recursive: true, force: true });
expect(config.name).toBe("epic");
expect(config.taskPhases).toEqual(["developer", "qa"]);
expect(config.finalPhases).toEqual(["finalize"]);
expect(config.phases.length).toBeGreaterThanOrEqual(3);
});

it("includes 'epic' in BUNDLED_WORKFLOW_NAMES", () => {
expect(BUNDLED_WORKFLOW_NAMES).toContain("epic");
});
});
8 changes: 8 additions & 0 deletions src/lib/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ export interface RunProgress {
currentPhase?: string; // Pipeline phase: "explorer" | "developer" | "qa" | "reviewer" | "finalize"
costByPhase?: Record<string, number>; // e.g. { explorer: 0.10, developer: 0.50 }
agentByPhase?: Record<string, string>; // e.g. { explorer: "claude-haiku-4-5", developer: "claude-sonnet-4-6" }
/** Epic mode: total number of child tasks. */
epicTaskCount?: number;
/** Epic mode: number of tasks completed so far. */
epicTasksCompleted?: number;
/** Epic mode: seed ID of the currently executing task. */
epicCurrentTaskId?: string;
/** Epic mode: per-task cost breakdown. */
epicCostByTask?: Record<string, number>;
}

export interface Metrics {
Expand Down
86 changes: 84 additions & 2 deletions src/lib/workflow-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,31 @@ export interface WorkflowConfig {
* @default "continue"
*/
onError?: OnErrorStrategy;
/**
* Epic mode: ordered list of phase names to execute per-task.
* When present, the pipeline executor runs these phases for each child task
* instead of using the top-level `phases` array.
*
* Example: `taskPhases: [developer, qa]` — each task runs developer→QA with retry.
* When absent (undefined), the pipeline runs in single-task mode using `phases`.
*/
taskPhases?: string[];
/**
* Epic mode: ordered list of phase names to execute once after all tasks complete.
* Only used when `taskPhases` is also set (epic mode).
*
* Example: `finalPhases: [finalize]` — run finalize once after all tasks pass.
* When absent in epic mode, defaults to no final phases.
*/
finalPhases?: string[];
/**
* Epic mode: maximum seconds allowed per task's phase execution.
* When a task's developer phase exceeds this timeout, the phase is terminated
* and the task is marked failed. Only used when `taskPhases` is set.
*
* @example `taskTimeout: 300` — 5 minute timeout per task
*/
taskTimeout?: number;
}

// ── Constants ─────────────────────────────────────────────────────────────────
Expand All @@ -241,7 +266,7 @@ const BUNDLED_WORKFLOWS_DIR = join(
);

/** Known workflow names with bundled defaults. */
export const BUNDLED_WORKFLOW_NAMES: ReadonlyArray<string> = ["default", "smoke"];
export const BUNDLED_WORKFLOW_NAMES: ReadonlyArray<string> = ["default", "smoke", "epic"];

// ── Validation ────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -432,6 +457,61 @@ export function validateWorkflowConfig(raw: unknown, workflowName: string): Work
config.onFailure = onFailure;
}

// ── Parse optional epic mode fields (taskPhases, finalPhases) ──────────
if (raw["taskPhases"] !== undefined) {
if (!Array.isArray(raw["taskPhases"])) {
throw new WorkflowConfigError(workflowName, "'taskPhases' must be an array of phase names");
}
const taskPhases: string[] = [];
for (let j = 0; j < raw["taskPhases"].length; j++) {
const pName = raw["taskPhases"][j];
if (typeof pName !== "string" || !pName) {
throw new WorkflowConfigError(workflowName, `taskPhases[${j}] must be a non-empty string`);
}
// Validate that referenced phase exists in the phases array
if (!phases.some((p) => p.name === pName)) {
throw new WorkflowConfigError(
workflowName,
`taskPhases[${j}] references phase '${pName}' which is not defined in phases`,
);
}
taskPhases.push(pName);
}
if (taskPhases.length > 0) {
config.taskPhases = taskPhases;
}
}
if (raw["finalPhases"] !== undefined) {
if (!Array.isArray(raw["finalPhases"])) {
throw new WorkflowConfigError(workflowName, "'finalPhases' must be an array of phase names");
}
const finalPhases: string[] = [];
for (let j = 0; j < raw["finalPhases"].length; j++) {
const pName = raw["finalPhases"][j];
if (typeof pName !== "string" || !pName) {
throw new WorkflowConfigError(workflowName, `finalPhases[${j}] must be a non-empty string`);
}
if (!phases.some((p) => p.name === pName)) {
throw new WorkflowConfigError(
workflowName,
`finalPhases[${j}] references phase '${pName}' which is not defined in phases`,
);
}
finalPhases.push(pName);
}
if (finalPhases.length > 0) {
config.finalPhases = finalPhases;
}
}

// ── Parse optional taskTimeout ─────────────────────────────────────────
if (raw["taskTimeout"] !== undefined) {
if (typeof raw["taskTimeout"] !== "number" || raw["taskTimeout"] <= 0) {
throw new WorkflowConfigError(workflowName, "taskTimeout must be a positive number (seconds)");
}
config.taskTimeout = raw["taskTimeout"];
}

// ── Parse optional onError strategy ─────────────────────────────────────
if (raw["onError"] !== undefined) {
const onError = raw["onError"];
Expand Down Expand Up @@ -584,7 +664,9 @@ export function resolveWorkflowName(seedType: string, labels?: string[]): string
}
}
}
return seedType === "smoke" ? "smoke" : "default";
if (seedType === "smoke") return "smoke";
if (seedType === "epic") return "epic";
return "default";
}

// ── Compatibility exports ─────────────────────────────────────────────────────
Expand Down
Loading
Loading