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,125 @@ 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 ] , {
219+ cwd : input . worktreePath ,
220+ } ) . catch ( ( err ) =>
221+ console . warn (
222+ `[ship] git add ${ file } failed: ${ ( err as Error ) . message } ` ,
223+ ) ,
224+ ) ;
225+ }
226+
227+ const commitMsg = `feat: #${ input . issueNumber } — ${ input . summary } \n\nCloses #${ input . issueNumber } ` ;
228+ await execa ( "git" , [ "commit" , "-m" , commitMsg ] , {
229+ cwd : input . worktreePath ,
230+ } ) ;
231+
232+ const { stdout : commit } = await execa ( "git" , [ "rev-parse" , "HEAD" ] , {
233+ cwd : input . worktreePath ,
234+ } ) ;
235+
236+ await execa ( "git" , [ "push" , "-u" , "origin" , currentBranch ] , {
237+ cwd : input . worktreePath ,
238+ } ) ;
239+
240+ const body = `## Summary\n\n${ input . summary } \n\nCloses #${ input . issueNumber } ` ;
241+ const { stdout : prUrl } = await execa (
242+ "gh" ,
243+ [
244+ "pr" ,
245+ "create" ,
246+ "--head" ,
247+ currentBranch ,
248+ "--title" ,
249+ `feat: #${ input . issueNumber } — ${ input . issueTitle } ` ,
250+ "--body" ,
251+ body ,
252+ ] ,
253+ { cwd : input . worktreePath } ,
254+ ) ;
255+
256+ const prNumberMatch = prUrl . match ( / \/ p u l l \/ ( \d + ) / ) ;
257+ const prNumber = prNumberMatch ? Number ( prNumberMatch [ 1 ] ) : null ;
258+
259+ return { prNumber, branch : currentBranch , commit : commit . trim ( ) } ;
260+ } ,
261+ } ) ;
262+
120263export const createFeaturePipeline = defineWorkflowFactory (
121264 ( input : WorkflowInput ) => ( {
122265 name : "feature-pipeline" ,
123266 tasks : {
124267 // PLAN phase — architect produces the technical plan.
125- // Sub-PR 2: wired as real defineAgent. Sub-PR 4 runs the executor.
126268 plan : {
127269 agent : architectAgent ,
128270 input : ( ) => ( {
@@ -132,30 +274,36 @@ export const createFeaturePipeline = defineWorkflowFactory(
132274 } ) ,
133275 } ,
134276
135- // BUILD phase — implement plan in worktree.
136- // Sub-PR 4: replace with engineering-senior-developer agent + session.
277+ // BUILD phase — senior-developer implements plan in worktree.
137278 build : {
138- fn : noopFn ,
279+ agent : seniorDeveloperAgent ,
139280 dependsOn : [ "plan" ] as const ,
140- input : ( ) => ( { worktreePath : input . worktreePath } ) ,
281+ input : ( ctx : {
282+ plan : {
283+ output : { plan : string ; affectedPackages : readonly string [ ] } ;
284+ } ;
285+ } ) => ( {
286+ issueNumber : input . issue . number ,
287+ issueTitle : input . issue . title ,
288+ plan : ctx . plan . output . plan ,
289+ affectedPackages : [ ...ctx . plan . output . affectedPackages ] ,
290+ worktreePath : input . worktreePath ,
291+ } ) ,
141292 } ,
142293
143- // TEST phase — run `bun test` in worktree.
144- // Sub-PR 4: replace with a deterministic test-runner function.
294+ // TEST phase — run `bun test` in worktree deterministically.
145295 test : {
146- fn : noopFn ,
296+ fn : testFn ,
147297 dependsOn : [ "build" ] as const ,
148298 input : ( ) => ( { worktreePath : input . worktreePath } ) ,
149299 } ,
150300
151301 // 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).
302+ // Depend on both `test` (for ordering) and `plan` (for the plan text
303+ // passed to the reviewer's prompt). `CtxFor` only exposes direct
304+ // dependencies, so `plan` must be listed here.
154305 verify : {
155306 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.
159307 dependsOn : [ "test" , "plan" ] as const ,
160308 input : ( ctx : {
161309 plan : { output : { plan : string } } ;
@@ -167,11 +315,22 @@ export const createFeaturePipeline = defineWorkflowFactory(
167315 } ,
168316
169317 // SHIP phase — push branch + open PR via gh.
170- // Sub-PR 4: replace with ship role (or defineFunction, TBD).
171318 ship : {
172- fn : noopFn ,
173- dependsOn : [ "verify" ] as const ,
174- input : ( ) => ( { issueNumber : input . issue . number } ) ,
319+ fn : shipFn ,
320+ dependsOn : [ "verify" , "build" ] as const ,
321+ input : ( ctx : {
322+ build : {
323+ output : { filesChanged : readonly string [ ] ; summary : string } ;
324+ } ;
325+ verify : { output : { gate : "APPROVED" | "NEEDS_WORK" } } ;
326+ } ) => ( {
327+ issueNumber : input . issue . number ,
328+ issueTitle : input . issue . title ,
329+ worktreePath : input . worktreePath ,
330+ filesChanged : [ ...ctx . build . output . filesChanged ] ,
331+ summary : ctx . build . output . summary ,
332+ gate : ctx . verify . output . gate ,
333+ } ) ,
175334 } ,
176335 } ,
177336 } ) ,
0 commit comments