@@ -21,13 +21,14 @@ func LoadWithIncludes(dir string) (*Config, error) {
2121 return loader .load ()
2222}
2323
24- // includeLoader holds shared state while recursively loading included task files.
24+ // includeLoader holds shared state while recursively loading task files
25+ // pulled in via `includes:` (namespaced) and `flatten:` (no namespace).
2526type includeLoader struct {
26- root * Config
27- rootDir string
28- seenDotenv map [string ]struct {} // deduplicates dotenv files across includes
29- dotenvVars map [string ]string // accumulated dotenv variables
30- includeStack map [string ]struct {} // dirs on the current recursion path (cycle detection)
27+ root * Config
28+ rootDir string
29+ seenDotenv map [string ]struct {} // deduplicates dotenv files across loaded files
30+ dotenvVars map [string ]string // accumulated dotenv variables
31+ loadStack map [string ]struct {} // absolute file paths currently being loaded (cycle detection)
3132}
3233
3334func newIncludeLoader (root * Config ) (* includeLoader , error ) {
@@ -38,17 +39,32 @@ func newIncludeLoader(root *Config) (*includeLoader, error) {
3839 }
3940
4041 return & includeLoader {
41- root : root ,
42- rootDir : root .Dir ,
43- seenDotenv : seenDotenv ,
44- dotenvVars : dotenvVars ,
45- includeStack : map [string ]struct {}{root .Dir : {}},
42+ root : root ,
43+ rootDir : root .Dir ,
44+ seenDotenv : seenDotenv ,
45+ dotenvVars : dotenvVars ,
46+ loadStack : map [string ]struct {}{filepath . Join ( root .Dir , fileName ) : {}},
4647 }, nil
4748}
4849
4950func (l * includeLoader ) load () (* Config , error ) {
5051 l .root .Namespaces = make (map [string ]string )
5152
53+ // Process flatten files first (their tasks merge into the root namespace).
54+ // Order vs. includes doesn't matter for correctness because both honor
55+ // "first defined wins"; the root's own tasks were already loaded at Parse.
56+ for _ , p := range l .root .Flatten {
57+ if err := l .loadFlatten (flattenRequest {
58+ parentDir : l .rootDir ,
59+ parentFile : filepath .Join (l .rootDir , fileName ),
60+ path : p ,
61+ namespace : "" ,
62+ ancestorDir : l .rootDir ,
63+ }); err != nil {
64+ return nil , err
65+ }
66+ }
67+
5268 for _ , includeName := range l .root .Includes {
5369 if err := l .loadInclude (includeRequest {
5470 parentDir : l .rootDir ,
@@ -77,18 +93,33 @@ func (r includeRequest) childDir() (string, error) {
7793 return filepath .Abs (filepath .Join (r .parentDir , r .name ))
7894}
7995
80- // loadInclude parses an included task file, loads nested includes, then merges it into the root.
96+ // loadInclude parses an included task file, loads nested includes and flatten
97+ // files, then merges it into the root.
8198func (l * includeLoader ) loadInclude (req includeRequest ) error {
8299 included , err := l .parseInclude (req )
83100 if err != nil {
84101 return err
85102 }
86- defer l .leaveInclude ( included .Dir )
103+ defer l .leaveLoad ( filepath . Join ( included .Dir , fileName ) )
87104
88105 if err := l .loadIncludeDotenv (included ); err != nil {
89106 return err
90107 }
91108
109+ // Process flatten files declared inside this include — their tasks merge
110+ // at this include's namespace.
111+ for _ , p := range included .Flatten {
112+ if err := l .loadFlatten (flattenRequest {
113+ parentDir : included .Dir ,
114+ parentFile : filepath .Join (included .Dir , fileName ),
115+ path : p ,
116+ namespace : included .Namespace ,
117+ ancestorDir : included .Dir ,
118+ }); err != nil {
119+ return err
120+ }
121+ }
122+
92123 for _ , nested := range nestedIncludes (included ) {
93124 if err := l .loadInclude (nested ); err != nil {
94125 return err
@@ -105,14 +136,15 @@ func (l *includeLoader) parseInclude(req includeRequest) (*includedConfig, error
105136 if err != nil {
106137 return nil , fmt .Errorf ("resolving include %q from %s: %w" , req .namespace , req .parentFile (), err )
107138 }
108- if _ , onPath := l .includeStack [childDir ]; onPath {
139+ expectedFile := filepath .Join (childDir , fileName )
140+ if _ , onPath := l .loadStack [expectedFile ]; onPath {
109141 return nil , fmt .Errorf ("cyclic include %q detected in %s" , req .namespace , req .parentFile ())
110142 }
111- l .includeStack [ childDir ] = struct {}{}
143+ l .loadStack [ expectedFile ] = struct {}{}
112144
113145 child , err := Parse (childDir )
114146 if err != nil {
115- delete (l .includeStack , childDir )
147+ delete (l .loadStack , expectedFile )
116148 return nil , fmt .Errorf ("loading include %q from %s: %w" , req .namespace , req .parentFile (), err )
117149 }
118150
@@ -125,8 +157,8 @@ func (l *includeLoader) parseInclude(req includeRequest) (*includedConfig, error
125157 return included , nil
126158}
127159
128- func (l * includeLoader ) leaveInclude ( dir string ) {
129- delete (l .includeStack , dir )
160+ func (l * includeLoader ) leaveLoad ( filePath string ) {
161+ delete (l .loadStack , filePath )
130162}
131163
132164func (l * includeLoader ) loadIncludeDotenv (included * includedConfig ) error {
@@ -161,6 +193,83 @@ func nestedIncludes(parent *includedConfig) []includeRequest {
161193 return requests
162194}
163195
196+ // flattenRequest describes a single `flatten:` entry pulled from a parent
197+ // task file. Tasks loaded via flatten merge into the parent's namespace
198+ // (or the root namespace when invoked from the root) without a prefix.
199+ type flattenRequest struct {
200+ parentDir string // dir of the file that declared this flatten (path resolution base)
201+ parentFile string // absolute path of that file (for error messages and cycle detection)
202+ path string // the path string written in YAML
203+ namespace string // namespace at which to merge tasks ("" means the root)
204+ ancestorDir string // dir of the namespaced ancestor (root, or include dir) for resolving task.Dir
205+ }
206+
207+ // loadFlatten parses a flatten YAML file, resolves its nested flatten/includes,
208+ // then merges its tasks into l.root at the given namespace.
209+ func (l * includeLoader ) loadFlatten (req flattenRequest ) error {
210+ absPath , err := filepath .Abs (resolvePath (req .parentDir , req .path ))
211+ if err != nil {
212+ return fmt .Errorf ("resolving flatten %q from %s: %w" , req .path , req .parentFile , err )
213+ }
214+ if _ , onPath := l .loadStack [absPath ]; onPath {
215+ return fmt .Errorf ("cyclic flatten %q detected in %s" , req .path , req .parentFile )
216+ }
217+ l .loadStack [absPath ] = struct {}{}
218+ defer l .leaveLoad (absPath )
219+
220+ flattenedDir := filepath .Dir (absPath )
221+ flattened , err := parseFile (absPath , flattenedDir )
222+ if err != nil {
223+ return fmt .Errorf ("loading flatten %q from %s: %w" , req .path , req .parentFile , err )
224+ }
225+
226+ // Load this file's dotenv (paths resolve relative to the file's own dir).
227+ childDotenv , err := loadDotenvFiles (flattened .Dir , flattened .Dotenv , l .seenDotenv )
228+ if err != nil {
229+ return fmt .Errorf ("loading dotenv for flatten %q from %s: %w" , req .path , req .parentFile , err )
230+ }
231+ for k , v := range childDotenv {
232+ if _ , exists := l .dotenvVars [k ]; ! exists {
233+ l .dotenvVars [k ] = v
234+ }
235+ }
236+
237+ // Recurse: nested flatten files keep the same namespace and ancestor.
238+ for _ , p := range flattened .Flatten {
239+ if err := l .loadFlatten (flattenRequest {
240+ parentDir : flattened .Dir ,
241+ parentFile : absPath ,
242+ path : p ,
243+ namespace : req .namespace ,
244+ ancestorDir : req .ancestorDir ,
245+ }); err != nil {
246+ return err
247+ }
248+ }
249+
250+ // Recurse: nested includes get sub-namespaces under our current one.
251+ for _ , name := range flattened .Includes {
252+ if err := l .loadInclude (includeRequest {
253+ parentDir : flattened .Dir ,
254+ name : name ,
255+ namespace : namespaceJoin (req .namespace , name ),
256+ }); err != nil {
257+ return err
258+ }
259+ }
260+
261+ l .mergeVars (flattened .Vars )
262+ l .mergeFlattenedTasks (flattened , req .namespace , req .ancestorDir )
263+ return nil
264+ }
265+
266+ func namespaceJoin (prefix , name string ) string {
267+ if prefix == "" {
268+ return name
269+ }
270+ return prefix + ":" + name
271+ }
272+
164273// mergeVars merges child global vars into the root. Root vars win conflicts.
165274func (l * includeLoader ) mergeVars (vars map [string ]Var ) {
166275 if len (vars ) == 0 {
@@ -183,6 +292,27 @@ func (l *includeLoader) mergeTasks(included *includedConfig) {
183292 }
184293}
185294
295+ // mergeFlattenedTasks merges tasks from a flattened file into l.root at the
296+ // given namespace. With an empty namespace, tasks land in the root unchanged
297+ // (this is the agentic-platform pattern for splitting one file across many).
298+ // First defined wins, so a parent file can override a flattened task by
299+ // declaring it locally with the same name.
300+ func (l * includeLoader ) mergeFlattenedTasks (flattened * Config , namespace , ancestorDir string ) {
301+ for _ , name := range slices .Sorted (maps .Keys (flattened .Tasks )) {
302+ finalName := namespaceJoin (namespace , name )
303+ if _ , exists := l .root .Tasks [finalName ]; exists {
304+ continue // first wins (root or earlier flatten file beats this one)
305+ }
306+ task := flattened .Tasks [name ]
307+ makeTaskDirAbsolute (& task , ancestorDir )
308+ if namespace != "" {
309+ ic := & includedConfig {Config : flattened , Namespace : namespace }
310+ namespaceLocalReferences (& task , ic )
311+ }
312+ l .root .Tasks [finalName ] = task
313+ }
314+ }
315+
186316func normalizedIncludedTask (included * includedConfig , name string ) Task {
187317 task := included .Tasks [name ]
188318 makeTaskDirAbsolute (& task , included .Dir )
0 commit comments