@@ -77,6 +77,63 @@ const BROWNFIELD_MARKER_EXTENSIONS = ['.sln', '.csproj', '.fsproj', '.vbproj', '
7777
7878const BROWNFIELD_SOURCE_DIRS = [ 'src' , 'app' , 'lib' , 'pkg' , 'cmd' , 'tests' , 'test' , 'spec' ]
7979
80+ // v0.20.3 #2 — INTENT.md is the explicit greenfield-seed marker. A directory
81+ // whose only contentful files are INTENT.md (plus neutral git metadata) is
82+ // greenfield by design, regardless of tracked vs untracked status. Caught
83+ // from the v0.20.2 quizr greenfield-friend dogfood (2026-05-14): a fresh
84+ // `git init` + write INTENT.md was misclassifying as brownfield because
85+ // the untracked-files scan picked up INTENT.md and treated it like
86+ // ordinary source content.
87+ const GREENFIELD_SEED_FILES = [ 'INTENT.md' ]
88+ const NEUTRAL_METADATA_ENTRIES = new Set ( [
89+ '.git' ,
90+ '.code-oz' ,
91+ '.gitignore' ,
92+ '.gitattributes' ,
93+ ] )
94+
95+ async function listGitTrackedPaths ( cwd : string ) : Promise < string [ ] > {
96+ try {
97+ const proc = Bun . spawn ( [ 'git' , '-C' , cwd , 'ls-files' ] , {
98+ stdout : 'pipe' ,
99+ stderr : 'pipe' ,
100+ } )
101+ const text = await new Response ( proc . stdout ) . text ( )
102+ await proc . exited
103+ return text
104+ . split ( '\n' )
105+ . map ( ( line ) => line . trim ( ) )
106+ . filter ( ( line ) => line . length > 0 && ! line . startsWith ( '.code-oz/' ) )
107+ } catch {
108+ return [ ]
109+ }
110+ }
111+
112+ async function isOnlyGreenfieldSeed ( cwd : string ) : Promise < boolean > {
113+ // 1. Walk top-level entries. Anything outside {seed, neutral metadata}
114+ // disqualifies — a real brownfield project has source files or
115+ // lockfiles at root that would show up here.
116+ const entries = await readdir ( cwd ) . catch ( ( ) => [ ] as string [ ] )
117+ for ( const e of entries ) {
118+ if ( GREENFIELD_SEED_FILES . includes ( e ) ) continue
119+ if ( NEUTRAL_METADATA_ENTRIES . has ( e ) ) continue
120+ return false
121+ }
122+ // 2. If the directory is a git repo, also confirm git tracks nothing
123+ // outside the seed list. A user could have committed INTENT.md
124+ // (`git add INTENT.md && git commit`) and the dirent walk still
125+ // looks seed-only — but tracked content elsewhere in the tree
126+ // would disqualify. Filtering on the basename matches the
127+ // top-level seed pattern (nested INTENT.md is not a seed).
128+ if ( ! ( await pathExists ( join ( cwd , '.git' ) ) ) ) return true
129+ const tracked = await listGitTrackedPaths ( cwd )
130+ for ( const f of tracked ) {
131+ if ( GREENFIELD_SEED_FILES . includes ( f ) ) continue
132+ return false
133+ }
134+ return true
135+ }
136+
80137async function gitTracksFiles ( cwd : string ) : Promise < boolean > {
81138 try {
82139 const proc = Bun . spawn ( [ 'git' , '-C' , cwd , 'ls-files' ] , {
@@ -121,6 +178,12 @@ async function gitHasContentfulUntrackedFiles(cwd: string): Promise<boolean> {
121178}
122179
123180export async function detectProfile ( cwd : string ) : Promise < Profile > {
181+ // Greenfield-seed early exit (v0.20.3 #2): INTENT.md is the explicit
182+ // seed marker. A directory whose only contentful files are INTENT.md
183+ // (plus neutral git metadata like .gitignore) is greenfield by design,
184+ // regardless of whether INTENT.md is tracked or untracked.
185+ if ( await isOnlyGreenfieldSeed ( cwd ) ) return 'greenfield'
186+
124187 if ( await gitTracksFiles ( cwd ) ) return 'brownfield'
125188 if ( await gitHasContentfulUntrackedFiles ( cwd ) ) return 'brownfield'
126189
0 commit comments