@@ -338,18 +338,24 @@ export function detectFieldSource(
338338 rawProjectConfig : Record < string , any > | null ,
339339 rawPrefs : Record < string , any > | null ,
340340) : FieldSource {
341- // L2 check for dual-layer and L2-only fields
341+ // L2 check for dual-layer and L2-only fields.
342+ // Type guards MUST match extractAllowlistedPreferences() in config-loader.ts
343+ // to avoid showing "(user)" for values that the merge layer would reject.
342344 if ( ( field . layer === "L1+L2" || field . layer === "L2" ) && field . prefsKey && rawPrefs ) {
343345 const prefVal = rawPrefs [ field . prefsKey ] ;
344346 if ( field . fieldType === "string" ) {
345- // String rule: non-undefined AND non-empty → (user)
346- if ( prefVal !== undefined && prefVal !== "" ) return "user" ;
347+ // String rule: must be typeof string, non-empty → (user)
348+ // Matches: `typeof raw.X === "string"` AND applyUserPreferences `val !== "" `
349+ if ( typeof prefVal === "string" && prefVal !== "" ) return "user" ;
347350 } else if ( field . fieldType === "enum" ) {
348- // Enum rule: any defined value → (user)
349- if ( prefVal !== undefined ) return "user" ;
351+ // Enum rule: must be a valid enum value from the field's values array.
352+ // Matches extractAllowlistedPreferences which checks exact enum membership
353+ // (e.g., raw.spawnMode === "tmux" || raw.spawnMode === "subprocess").
354+ if ( prefVal !== undefined && field . values && field . values . includes ( String ( prefVal ) ) ) return "user" ;
350355 } else if ( field . fieldType === "number" ) {
351- // Number rule: any defined value → (user)
352- if ( prefVal !== undefined ) return "user" ;
356+ // Number rule: must be typeof number and finite → (user)
357+ // Matches: `typeof raw.X === "number" && Number.isFinite(raw.X)`
358+ if ( typeof prefVal === "number" && Number . isFinite ( prefVal ) ) return "user" ;
353359 }
354360 }
355361
@@ -427,8 +433,8 @@ export function validateFieldInput(field: FieldDef, input: string): ValidationRe
427433 switch ( field . fieldType ) {
428434 case "number" : {
429435 const num = Number ( input . trim ( ) ) ;
430- if ( ! Number . isFinite ( num ) || num < 0 ) {
431- return { valid : false , error : "Must be a positive number " } ;
436+ if ( ! Number . isFinite ( num ) || num <= 0 ) {
437+ return { valid : false , error : "Must be a positive integer " } ;
432438 }
433439 // Integer check for most number fields
434440 if ( ! Number . isInteger ( num ) ) {
@@ -979,18 +985,45 @@ function truncateLine(text: string, width: number): string {
979985// ── JSON-Only Footer ─────────────────────────────────────────────────
980986
981987/**
982- * Generate a footer note about JSON-only fields related to a section.
988+ * Map from section name to the config subsection prefixes it covers.
989+ * Used to dynamically discover JSON-only sibling fields.
983990 */
984- function getJsonOnlyFooterForSection ( section : SectionDef , _config : TaskplaneConfig ) : string | null {
985- // Map sections to their JSON-only siblings
986- const sectionJsonOnly : Record < string , string [ ] > = {
987- "Assignment" : [ "sizeWeights" ] ,
988- "Pre-Warm" : [ "commands" , "always" ] ,
989- "Merge" : [ "verify" ] ,
990- } ;
991+ const SECTION_CONFIG_PREFIXES : Record < string , string [ ] > = {
992+ "Orchestrator" : [ "orchestrator.orchestrator" ] ,
993+ "Dependencies" : [ "orchestrator.dependencies" ] ,
994+ "Assignment" : [ "orchestrator.assignment" ] ,
995+ "Pre-Warm" : [ "orchestrator.preWarm" ] ,
996+ "Merge" : [ "orchestrator.merge" ] ,
997+ "Failure Policy" : [ "orchestrator.failure" ] ,
998+ "Monitoring" : [ "orchestrator.monitoring" ] ,
999+ "Worker" : [ "taskRunner.worker" ] ,
1000+ "Reviewer" : [ "taskRunner.reviewer" ] ,
1001+ "Context Limits" : [ "taskRunner.context" ] ,
1002+ } ;
9911003
992- const jsonFields = sectionJsonOnly [ section . name ] ;
993- if ( ! jsonFields || jsonFields . length === 0 ) return null ;
1004+ /**
1005+ * Generate a footer note about JSON-only fields related to a section.
1006+ *
1007+ * Dynamically discovers uncovered fields under the same config subsection
1008+ * prefix, so new fields added to the schema auto-appear in footers.
1009+ */
1010+ function getJsonOnlyFooterForSection ( section : SectionDef , config : TaskplaneConfig ) : string | null {
1011+ const prefixes = SECTION_CONFIG_PREFIXES [ section . name ] ;
1012+ if ( ! prefixes ) return null ;
1013+
1014+ // Find all uncovered leaf fields under these prefixes
1015+ const uncoveredFields : string [ ] = [ ] ;
1016+ walkConfig ( config , "" , ( path , _value ) => {
1017+ if ( COVERED_PATHS . has ( path ) ) return ; // Already editable
1018+ for ( const prefix of prefixes ) {
1019+ if ( path . startsWith ( prefix + "." ) ) {
1020+ // Extract the field name (last segment)
1021+ const fieldName = path . split ( "." ) . pop ( ) || path ;
1022+ uncoveredFields . push ( fieldName ) ;
1023+ }
1024+ }
1025+ } ) ;
9941026
995- return `+ ${ jsonFields . join ( ", " ) } (edit JSON directly)` ;
1027+ if ( uncoveredFields . length === 0 ) return null ;
1028+ return `+ ${ uncoveredFields . join ( ", " ) } (edit JSON directly)` ;
9961029}
0 commit comments