11// Feature pipeline — PLAN → BUILD → TEST → VERIFY → SHIP DAG.
22//
3- // Sub-PR 2: two tasks (plan, verify) are now real `defineAgent` calls that
4- // load their prompt from the role library via `loadRole()`. The remaining
5- // three (build, test, ship) stay as `defineFunction` no-ops — they land as
6- // real agents in sub-PR 4 alongside executor dispatch.
3+ // PR C: build, test, and ship are now real implementations:
4+ // plan — defineAgent using engineering-software-architect role (codex) ✅
5+ // build — defineAgent using engineering-senior-developer role (codex)
6+ // test — defineFunction spawning `bun test` with 10-min timeout
7+ // verify — defineAgent using engineering-code-reviewer role (codex) ✅
8+ // ship — defineFunction — git add/commit/push + gh pr create
79//
8- // This mixed-node shape is intentional: it exercises both the agent-with-
9- // role-prompt path and the deterministic `defineFunction` path inside the
10- // same DAG, proving the end-to-end wiring without yet calling the executor.
10+ // Session on build is deferred to PR D where fix→test iteration needs it.
1111
1212import {
1313 defineAgent ,
1414 defineFunction ,
1515 defineWorkflowFactory ,
1616} from "@ageflow/core" ;
17+ import { execa } from "execa" ;
1718import { z } from "zod" ;
1819import { loadRoleSync } from "../shared/role-loader.js" ;
1920import type { WorkflowInput } from "../shared/types.js" ;
2021
21- const noopFn = defineFunction ( {
22- name : "noop" ,
23- input : z . object ( { } ) . passthrough ( ) ,
24- output : z . object ( { } ) ,
25- execute : async ( ) => ( { } ) ,
26- } ) ;
27-
2822// PLAN — engineering-software-architect produces a technical plan.
2923// Uses codex (primary runner). Output schema is a tight Zod object so raw
3024// agent stdout never flows downstream unsanitized (security boundary).
@@ -71,6 +65,41 @@ const architectAgent = defineAgent({
7165 } ,
7266} ) ;
7367
68+ // BUILD — engineering-senior-developer implements the plan in the worktree.
69+ // Session on build deferred to PR D where fix→test iteration needs it.
70+ const seniorDeveloperAgent = defineAgent ( {
71+ runner : "codex" ,
72+ input : z . object ( {
73+ issueNumber : z . number ( ) . int ( ) . positive ( ) ,
74+ issueTitle : z . string ( ) ,
75+ plan : z . string ( ) ,
76+ affectedPackages : z . array ( z . string ( ) ) ,
77+ worktreePath : z . string ( ) ,
78+ } ) ,
79+ output : z . object ( {
80+ filesChanged : z . array ( z . string ( ) ) ,
81+ summary : z . string ( ) ,
82+ typecheckPassed : z . boolean ( ) ,
83+ } ) ,
84+ prompt : ( input ) => {
85+ const role = loadRoleSync ( "engineering-senior-developer" ) ;
86+ return [
87+ role . body ,
88+ "---" ,
89+ `Feature issue #${ input . issueNumber } : ${ input . issueTitle } ` ,
90+ "" ,
91+ "Plan from architect:" ,
92+ input . plan ,
93+ "" ,
94+ `Affected packages: ${ input . affectedPackages . join ( ", " ) } ` ,
95+ `Worktree: ${ input . worktreePath } ` ,
96+ "" ,
97+ "Implement per the plan. Run typecheck + tests locally before returning." ,
98+ "Only commit reality: typecheckPassed must reflect actual exit code." ,
99+ ] . join ( "\n" ) ;
100+ } ,
101+ } ) ;
102+
74103// VERIFY — engineering-code-reviewer reads the diff and decides the gate.
75104const codeReviewerAgent = defineAgent ( {
76105 runner : "codex" ,
@@ -117,12 +146,124 @@ const codeReviewerAgent = defineAgent({
117146 } ,
118147} ) ;
119148
149+ // TEST — deterministic `bun test` runner. Captures output + exit code.
150+ // Timeout 10 minutes — long enough for slow CI-like runs.
151+ // reject: false — lets us capture the result on non-zero exit instead of throwing.
152+ const testFn = defineFunction ( {
153+ name : "test" ,
154+ input : z . object ( {
155+ worktreePath : z . string ( ) ,
156+ } ) ,
157+ output : z . object ( {
158+ passed : z . boolean ( ) ,
159+ output : z . string ( ) ,
160+ exitCode : z . number ( ) . int ( ) ,
161+ } ) ,
162+ execute : async ( input ) => {
163+ try {
164+ const { stdout, stderr, exitCode } = await execa ( "bun" , [ "test" ] , {
165+ cwd : input . worktreePath ,
166+ reject : false ,
167+ timeout : 600_000 ,
168+ } ) ;
169+ const combinedOutput = `${ stdout } \n${ stderr } ` . slice ( - 4000 ) ; // last 4KB
170+ return {
171+ passed : exitCode === 0 ,
172+ output : combinedOutput ,
173+ exitCode : exitCode ?? - 1 ,
174+ } ;
175+ } catch ( err ) {
176+ return {
177+ passed : false ,
178+ output : err instanceof Error ? err . message : String ( err ) ,
179+ exitCode : - 1 ,
180+ } ;
181+ }
182+ } ,
183+ } ) ;
184+
185+ // SHIP — deterministic git + gh. Runs only when verify gate = APPROVED.
186+ // Reads the current branch from the worktree so it works regardless of
187+ // how the worktree was created (branch name already set by createWorktree).
188+ const shipFn = defineFunction ( {
189+ name : "ship" ,
190+ input : z . object ( {
191+ issueNumber : z . number ( ) . int ( ) . positive ( ) ,
192+ issueTitle : z . string ( ) ,
193+ worktreePath : z . string ( ) ,
194+ filesChanged : z . array ( z . string ( ) ) ,
195+ summary : z . string ( ) ,
196+ gate : z . enum ( [ "APPROVED" , "NEEDS_WORK" ] ) ,
197+ } ) ,
198+ output : z . object ( {
199+ prNumber : z . number ( ) . int ( ) . positive ( ) . nullable ( ) ,
200+ branch : z . string ( ) ,
201+ commit : z . string ( ) ,
202+ } ) ,
203+ execute : async ( input ) => {
204+ if ( input . gate !== "APPROVED" ) {
205+ throw new Error ( `ship blocked: verify gate = ${ input . gate } ` ) ;
206+ }
207+
208+ // Read the branch the worktree is already on (set by createWorktree).
209+ const { stdout : branch } = await execa (
210+ "git" ,
211+ [ "branch" , "--show-current" ] ,
212+ { cwd : input . worktreePath } ,
213+ ) ;
214+ const currentBranch = branch . trim ( ) ;
215+
216+ // Stage changed files. Guard against hallucinated paths: skip files that fail.
217+ for ( const file of input . filesChanged ) {
218+ await execa ( "git" , [ "add" , "--" , file ] , { cwd : input . worktreePath } ) . catch (
219+ ( err ) =>
220+ console . warn (
221+ `[ship] git add ${ file } failed: ${ ( err as Error ) . message } ` ,
222+ ) ,
223+ ) ;
224+ }
225+
226+ const commitMsg = `feat: #${ input . issueNumber } — ${ input . summary } \n\nCloses #${ input . issueNumber } ` ;
227+ await execa ( "git" , [ "commit" , "-m" , commitMsg ] , {
228+ cwd : input . worktreePath ,
229+ } ) ;
230+
231+ const { stdout : commit } = await execa ( "git" , [ "rev-parse" , "HEAD" ] , {
232+ cwd : input . worktreePath ,
233+ } ) ;
234+
235+ await execa ( "git" , [ "push" , "-u" , "origin" , currentBranch ] , {
236+ cwd : input . worktreePath ,
237+ } ) ;
238+
239+ const body = `## Summary\n\n${ input . summary } \n\nCloses #${ input . issueNumber } ` ;
240+ const { stdout : prUrl } = await execa (
241+ "gh" ,
242+ [
243+ "pr" ,
244+ "create" ,
245+ "--head" ,
246+ currentBranch ,
247+ "--title" ,
248+ `feat: #${ input . issueNumber } — ${ input . issueTitle } ` ,
249+ "--body" ,
250+ body ,
251+ ] ,
252+ { cwd : input . worktreePath } ,
253+ ) ;
254+
255+ const prNumberMatch = prUrl . match ( / \/ p u l l \/ ( \d + ) / ) ;
256+ const prNumber = prNumberMatch ? Number ( prNumberMatch [ 1 ] ) : null ;
257+
258+ return { prNumber, branch : currentBranch , commit : commit . trim ( ) } ;
259+ } ,
260+ } ) ;
261+
120262export const createFeaturePipeline = defineWorkflowFactory (
121263 ( input : WorkflowInput ) => ( {
122264 name : "feature-pipeline" ,
123265 tasks : {
124266 // PLAN phase — architect produces the technical plan.
125- // Sub-PR 2: wired as real defineAgent. Sub-PR 4 runs the executor.
126267 plan : {
127268 agent : architectAgent ,
128269 input : ( ) => ( {
@@ -132,30 +273,36 @@ export const createFeaturePipeline = defineWorkflowFactory(
132273 } ) ,
133274 } ,
134275
135- // BUILD phase — implement plan in worktree.
136- // Sub-PR 4: replace with engineering-senior-developer agent + session.
276+ // BUILD phase — senior-developer implements plan in worktree.
137277 build : {
138- fn : noopFn ,
278+ agent : seniorDeveloperAgent ,
139279 dependsOn : [ "plan" ] as const ,
140- input : ( ) => ( { worktreePath : input . worktreePath } ) ,
280+ input : ( ctx : {
281+ plan : {
282+ output : { plan : string ; affectedPackages : readonly string [ ] } ;
283+ } ;
284+ } ) => ( {
285+ issueNumber : input . issue . number ,
286+ issueTitle : input . issue . title ,
287+ plan : ctx . plan . output . plan ,
288+ affectedPackages : [ ...ctx . plan . output . affectedPackages ] ,
289+ worktreePath : input . worktreePath ,
290+ } ) ,
141291 } ,
142292
143- // TEST phase — run `bun test` in worktree.
144- // Sub-PR 4: replace with a deterministic test-runner function.
293+ // TEST phase — run `bun test` in worktree deterministically.
145294 test : {
146- fn : noopFn ,
295+ fn : testFn ,
147296 dependsOn : [ "build" ] as const ,
148297 input : ( ) => ( { worktreePath : input . worktreePath } ) ,
149298 } ,
150299
151300 // VERIFY phase — code-reviewer returns APPROVED / NEEDS_WORK.
152- // Sub-PR 2: wired as real defineAgent. Reality-checker + security
153- // engineer land as parallel peers in sub-PR 3/4 (learning hooks).
301+ // Depend on both `test` (for ordering) and `plan` (for the plan text
302+ // passed to the reviewer's prompt). `CtxFor` only exposes direct
303+ // dependencies, so `plan` must be listed here.
154304 verify : {
155305 agent : codeReviewerAgent ,
156- // Depend on both `test` (for ordering) and `plan` (for the plan text
157- // passed to the reviewer's prompt). `CtxFor` only exposes direct
158- // dependencies, so `plan` must be listed here.
159306 dependsOn : [ "test" , "plan" ] as const ,
160307 input : ( ctx : {
161308 plan : { output : { plan : string } } ;
@@ -167,11 +314,22 @@ export const createFeaturePipeline = defineWorkflowFactory(
167314 } ,
168315
169316 // SHIP phase — push branch + open PR via gh.
170- // Sub-PR 4: replace with ship role (or defineFunction, TBD).
171317 ship : {
172- fn : noopFn ,
173- dependsOn : [ "verify" ] as const ,
174- input : ( ) => ( { issueNumber : input . issue . number } ) ,
318+ fn : shipFn ,
319+ dependsOn : [ "verify" , "build" ] as const ,
320+ input : ( ctx : {
321+ build : {
322+ output : { filesChanged : readonly string [ ] ; summary : string } ;
323+ } ;
324+ verify : { output : { gate : "APPROVED" | "NEEDS_WORK" } } ;
325+ } ) => ( {
326+ issueNumber : input . issue . number ,
327+ issueTitle : input . issue . title ,
328+ worktreePath : input . worktreePath ,
329+ filesChanged : [ ...ctx . build . output . filesChanged ] ,
330+ summary : ctx . build . output . summary ,
331+ gate : ctx . verify . output . gate ,
332+ } ) ,
175333 } ,
176334 } ,
177335 } ) ,
0 commit comments