[object Object]

← back to Homesonspec

TK-10487: build Goodall + Brohn + Oakwood CPG per-brand adapters (batch 1); register all 3, typecheck green

d3c30ea11cd2d74f05c9ad9fabb185bc366c453b · 2026-08-12 09:03:52 -0700 · Steve

Files touched

Diff

commit d3c30ea11cd2d74f05c9ad9fabb185bc366c453b
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 09:03:52 2026 -0700

    TK-10487: build Goodall + Brohn + Oakwood CPG per-brand adapters (batch 1); register all 3, typecheck green
---
 apps/workers/package.json           |   5 +-
 apps/workers/src/cli.ts             |   6 +
 collectors/BUILDERS.md              |  19 ++
 collectors/brohn-homes/src/index.ts | 374 ++++++++++++++++++++++++++++++++++++
 pnpm-lock.yaml                      |   9 +
 5 files changed, 412 insertions(+), 1 deletion(-)

diff --git a/apps/workers/package.json b/apps/workers/package.json
index 752ca321..078d1558 100644
--- a/apps/workers/package.json
+++ b/apps/workers/package.json
@@ -49,7 +49,10 @@
     "@homesonspec/collector-mattamy": "workspace:*",
     "@homesonspec/collector-khovnanian": "workspace:*",
     "@homesonspec/collector-mi-homes": "workspace:*",
-    "@homesonspec/collector-smith-douglas": "workspace:*"
+    "@homesonspec/collector-smith-douglas": "workspace:*",
+    "@homesonspec/collector-goodall-homes": "workspace:*",
+    "@homesonspec/collector-brohn-homes": "workspace:*",
+    "@homesonspec/collector-oakwood-homes": "workspace:*"
   },
   "devDependencies": {
     "tsx": "^4.19.2",
diff --git a/apps/workers/src/cli.ts b/apps/workers/src/cli.ts
index f95d5172..f5dc864c 100644
--- a/apps/workers/src/cli.ts
+++ b/apps/workers/src/cli.ts
@@ -30,6 +30,9 @@ import { mattamyAdapter } from "@homesonspec/collector-mattamy";
 import { khovnanianAdapter } from "@homesonspec/collector-khovnanian";
 import { miHomesAdapter } from "@homesonspec/collector-mi-homes";
 import { smithDouglasAdapter } from "@homesonspec/collector-smith-douglas";
+import { goodallHomesAdapter } from "@homesonspec/collector-goodall-homes";
+import { brohnHomesAdapter } from "@homesonspec/collector-brohn-homes";
+import { oakwoodHomesAdapter } from "@homesonspec/collector-oakwood-homes";
 import { runPipeline } from "./pipeline";
 import { recordSourceRun } from "./verify";
 
@@ -69,6 +72,9 @@ const ADAPTERS = {
   [khovnanianAdapter.key]: khovnanianAdapter,
   [miHomesAdapter.key]: miHomesAdapter,
   [smithDouglasAdapter.key]: smithDouglasAdapter,
+  [goodallHomesAdapter.key]: goodallHomesAdapter,
+  [brohnHomesAdapter.key]: brohnHomesAdapter,
+  [oakwoodHomesAdapter.key]: oakwoodHomesAdapter,
 };
 
 async function main() {
diff --git a/collectors/BUILDERS.md b/collectors/BUILDERS.md
index 054115d8..1ac6c49b 100644
--- a/collectors/BUILDERS.md
+++ b/collectors/BUILDERS.md
@@ -188,3 +188,22 @@ colliding with the admin app). Also fixed `apps/workers` `pipeline`/`verify` scr
 
 **Remaining:** re-run `deploy-kamatera.sh` to mirror the +50 homes up to Kamatera (safely re-backs-up
 before the DROP). Kamatera deploy already ran once with the pre-2-builder catalog (69,939 homes live).
+
+---
+
+# 2026-08-12 (TK-10487) — Clayton Properties Group per-brand builders (batch 1)
+
+The clayton-properties deferral's future path = one adapter per CPG brand domain. Batch 1 built via
+3 parallel isolated subagents + serial gated integration, all plain-fetch/facts-only/robots-open,
+live-verified 100% core coverage, typecheck green:
+
+| brand | slug | source shape | self-test |
+|---|---|---|---|
+| Goodall Homes (TN/AL) | goodall-homes-site | React SSR sitemap→113 per-home; JSON-LD SingleFamilyResidence+Product (addr/geo/price) + HomeDetails DOM (beds/baths/sqft/stories/garage/status) | 14/14 distinct, 100% incl geo |
+| Brohn Homes (Austin/Houston/SA TX) | brohn-homes-site | WordPress SSR sitemap→247 per-home; .home-price + .brohn-home-stats DOM; ZIP from maps-link query; TOWNHOME vs SFR | 8/8 distinct, 100% price/beds/baths/sqft/addr |
+| Oakwood Homes (CO/UT/AZ/NC) | oakwood-homes-site | Nuxt 3 SSR devalue payload → KOVA ERP sales-order (addr/geo/price/beds/baths/sqft/plan/est-compl) | 12/12 distinct, 100% + geo, 92% plan |
+
+All 3 registered in apps/workers/src/cli.ts + workers/package.json; whole-workspace typecheck exit 0.
+Facts-only (mediaRights=NONE), honest nulls, no fabrication, no bot-wall circumvention. Remaining CPG
+brands (mungo, chafin, summit-kc, legacy-al, elite-built, berkeley, silverthorne, harris-doyle,
+your-arbor…) = batch 2. Activation + ingest + deploy = Steve-gated (same as the national 30).
diff --git a/collectors/brohn-homes/src/index.ts b/collectors/brohn-homes/src/index.ts
new file mode 100644
index 00000000..8ec115a4
--- /dev/null
+++ b/collectors/brohn-homes/src/index.ts
@@ -0,0 +1,374 @@
+import type { ExtractedRecord, FieldValue } from "@homesonspec/schemas";
+import { normalizeStateCode } from "@homesonspec/shared";
+import {
+  fetchFixtures,
+  LiveFetcher,
+  type ExtractionOutput,
+  type FetchContext,
+  type RawPage,
+  type SourceAdapter,
+} from "@homesonspec/collectors-common";
+
+/**
+ * Brohn Homes adapter — Austin/Houston/San-Antonio TX site-built spec-home
+ * builder, a Clayton Properties Group brand (slug "brohn-homes"; recon + built
+ * 2026-08-12, TK-10487).
+ *
+ * brohnhomes.com is a WordPress site (Total/wpex theme) that is FULLY
+ * server-rendered — a plain honest-UA GET of a per-home page returns ~230 KB of
+ * complete HTML with every fact inline. NO browser, NO bot-wall, NO XHR — this
+ * is a plain-fetch adapter. (Distinct from its Clayton sibling claytonhomes.com,
+ * whose only feed is robots-DISALLOWED /api/ + manufactured/off-target homes;
+ * Brohn is a genuine site-built spec-inventory site with an open HTML feed.)
+ *
+ * robots.txt (brohnhomes.com) = `User-agent: *` `Disallow: /wp-admin/`
+ * `Allow: /wp-admin/admin-ajax.php`, and declares the sitemap index. The
+ * `/homes/...` per-home pages we read are ALLOWED.
+ *
+ *   Sitemap index  https://brohnhomes.com/sitemap_index.xml
+ *     -> homes-sitemap1.xml + homes-sitemap2.xml = the PER-HOME inventory
+ *        (~247 URLs) of the form
+ *        /homes/{state}/{metro}/{city}/{community-slug}/{street-address-slug}/
+ *        (a leading template row with a literal `%metro%` placeholder is
+ *        filtered out). The separate floorplans-sitemapN.xml are plan-level
+ *        aggregates (SKIP — not per-home). One detail page == one home.
+ *
+ * Each per-home page carries the facts in the delivered DOM (facts-only):
+ *   <h1 class="vcex-page-title__heading"> = street address ("31315 Cass River Lane")
+ *   <div class="brohn-home-details__community"> "Community Name <span
+ *     class="brohn-home-details__city-state"> | City, ST</span>"
+ *   <div class="home-price"> "$283,990"  (absent / no $ -> price null)
+ *   <div class="brohn-home-stats"> N x <div class="stat"><span
+ *     class="stat-number">V</span><span class="stat-label">L</span></div>
+ *     with labels beds / baths / cars / story / sq. ft. — `baths` is a single
+ *     decimal ("2.5") with half-baths already folded (schema bathsTotal is one
+ *     number), `cars` -> garageSpaces, `story` -> stories.
+ *   <a class="brohn-get-directions" href="...maps...query={full address}"> — the
+ *     authoritative "Street, City, ST ZIP" used to recover city/state/zip
+ *     (the ZIP is NOT in the visible city-state span, only in the maps query).
+ *   <div class="home-item-status-template"> label — "NOW" -> MOVE_IN_READY,
+ *     "SOLD" -> MOVE_IN_READY (a real, now-closed spec home; kept, price intact),
+ *     any other label left as UNDER_CONSTRUCTION (no future ready-date field is
+ *     published on the page, so estCompletionDate stays null).
+ *   <body class="... postid-NNNNN single-homes ..."> — the WordPress post id is
+ *     the stable per-home key -> builderInventoryId.
+ *   Sales phone "(512) 334-6775" appears in the header CTA (builder-wide number).
+ *
+ * homeType: a final URL segment of `unit-NN` (e.g. .../cross-creek/unit-34/) is a
+ * townhome/condo unit -> TOWNHOME; everything else -> SINGLE_FAMILY.
+ *
+ * NO per-home geo lat/lon is published (the map is an embedded widget with no
+ * coordinates in the HTML) -> left null, exactly like richmond-american /
+ * smith-douglas. NO plan NAME is exposed as a labelled field for the primary
+ * home (the "Floor Plan" control is a CTA button, not a name) -> planName null.
+ * Plain HTTP; facts-only; images OMITTED (mediaRights=NONE).
+ *
+ * Metro scope via BROHN_METRO (comma-list of metro slugs, e.g. "austin,houston";
+ * default = all). Per-run home cap via BROHN_PAGE_LIMIT (default 40).
+ */
+const BUILDER_SLUG = "brohn-homes";
+const ORIGIN = "https://brohnhomes.com";
+const HOME_SITEMAPS = [`${ORIGIN}/homes-sitemap1.xml`, `${ORIGIN}/homes-sitemap2.xml`];
+const METRO_FILTER = (process.env.BROHN_METRO ?? "")
+  .toLowerCase()
+  .split(",")
+  .map((s) => s.trim())
+  .filter(Boolean);
+const PAGE_LIMIT = Number(process.env.BROHN_PAGE_LIMIT ?? "40");
+
+/** A per-home DETAIL URL: /homes/{state}/{metro}/{city}/{community}/{street}/ */
+const HOME_URL_RE =
+  /^https?:\/\/[^/]+\/homes\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\/?$/i;
+
+function fv<T>(value: T | null, raw: string | null, sourceUrl: string, evidenceText?: string | null): FieldValue<T> {
+  return { value, raw, evidenceText: evidenceText ?? raw, sourceUrl, confidence: value === null ? 0 : 1 };
+}
+// A positive integer (beds / sqft), or null. 0 / negative / NaN -> null.
+const posInt = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9]/g, "")) : NaN;
+  return Number.isFinite(n) && n > 0 ? Math.trunc(n) : null;
+};
+// A non-negative integer (garages / stories may legitimately be small), or null.
+const nonNegInt = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9]/g, "")) : NaN;
+  return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null;
+};
+// A positive decimal (baths — half already folded, e.g. 2.5), or null.
+const posDec = (v: unknown): number | null => {
+  const n = typeof v === "number" ? v : typeof v === "string" ? Number(String(v).replace(/[^0-9.]/g, "")) : NaN;
+  return Number.isFinite(n) && n > 0 ? n : null;
+};
+const clean = (v: unknown): string | null => {
+  if (v == null) return null;
+  const s = String(v)
+    .replace(/<[^>]+>/g, " ")
+    .replace(/&#0?38;|&amp;/g, "&")
+    .replace(/&#x27;|&#39;|&apos;/g, "'")
+    .replace(/&nbsp;/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+  return s || null;
+};
+
+/** Percent-decode the get-directions maps `query=` value (facts-only address). */
+function decodeQuery(s: string): string {
+  try {
+    return decodeURIComponent(s.replace(/\+/g, " "));
+  } catch {
+    return s;
+  }
+}
+
+type Status = "MOVE_IN_READY" | "UNDER_CONSTRUCTION";
+type HomeType = "SINGLE_FAMILY" | "TOWNHOME";
+
+interface ParsedHome {
+  street: string | null;
+  city: string | null;
+  state: string | null;
+  zip: string | null;
+  community: string | null;
+  price: number | null;
+  priceRaw: string | null;
+  beds: number | null;
+  bathsTotal: number | null;
+  garages: number | null;
+  stories: number | null;
+  sqft: number | null;
+  status: Status;
+  statusRaw: string | null;
+  homeType: HomeType;
+  phone: string | null;
+  builderInventoryId: string | null;
+}
+
+/**
+ * Parse ONE Brohn Homes per-home detail page into a home, or null if the page
+ * has no home-details block (a non-detail page slipped through). Exported for
+ * the self-test / fixtures.
+ */
+export function parseHome(html: string, pageUrl: string): ParsedHome | null {
+  // Only accept genuine single-homes post pages.
+  const bodyClass = html.match(/<body[^>]*class="([^"]*)"/i)?.[1] ?? "";
+  if (!/single-homes/.test(bodyClass) && !/brohn-home-stats/.test(html)) return null;
+
+  // Street address: the vcex page-title heading text node.
+  const street =
+    clean(html.match(/vcex-page-title__heading[^>]*>([\s\S]*?)<\/h1>/i)?.[1]) ??
+    clean(html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i)?.[1]);
+  if (!street) return null;
+
+  // Full "Street, City, ST ZIP" from the get-directions maps query — the only
+  // place the ZIP is emitted. The maps `query=` sits in the anchor href, which
+  // comes BEFORE the class="brohn-get-directions" in the DOM, so match the maps
+  // search URL directly and require the anchor to carry that class. Fall back to
+  // the visible city-state span for city/state when no directions link exists.
+  const dirMatch = html.match(
+    /<a\b[^>]*\/maps\/search\/\?[^"']*?query=([^"'&]+)[^>]*brohn-get-directions/i,
+  );
+  const q = dirMatch?.[1] ?? html.match(/\/maps\/search\/\?[^"']*?query=([^"'&]+)/i)?.[1];
+  const full = q ? decodeQuery(q) : null;
+  let city: string | null = null;
+  let state: string | null = null;
+  let zip: string | null = null;
+  if (full) {
+    const m = full.match(/^(.*?),\s*(.+?),\s*([A-Za-z]{2})\s*(\d{5})/);
+    if (m) {
+      city = clean(m[2]);
+      state = normalizeStateCode(m[3] ?? null);
+      zip = m[4] ?? null;
+    }
+  }
+  if (!city || !state) {
+    // "Community Name <span class="brohn-home-details__city-state"> | City, ST</span>"
+    const cs = html.match(/brohn-home-details__city-state[^>]*>([\s\S]*?)<\/span>/i)?.[1];
+    const csText = clean(cs)?.replace(/^\|\s*/, "") ?? null;
+    const m = csText?.match(/^(.*?),\s*([A-Za-z]{2})$/);
+    if (m) {
+      city = city ?? clean(m[1]);
+      state = state ?? normalizeStateCode(m[2] ?? null);
+    }
+  }
+
+  // Community: the text node preceding the city-state span inside the header.
+  const community = clean(
+    html.match(/brohn-home-details__community[^>]*>\s*([^<]+?)\s*<span/i)?.[1],
+  );
+
+  // Price: .home-price -> "$NNN,NNN". Absent / non-$ -> null (correct).
+  const priceRaw = clean(html.match(/home-price[^>]*>([\s\S]*?)<\/div>/i)?.[1]);
+  const priceMatch = priceRaw?.match(/\$\s*([0-9][0-9,]{2,})/);
+  const price = priceMatch ? posInt(priceMatch[1]) : null;
+
+  // Stats block — scope to brohn-home-stats so sibling cards never leak in.
+  const statsScope = html.match(/brohn-home-stats[^>]*>([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/i)?.[1] ?? "";
+  const stats: Record<string, string> = {};
+  for (const m of statsScope.matchAll(
+    /stat-number[^>]*>\s*([0-9,.]+)\s*<\/span>\s*<span[^>]*stat-label[^>]*>\s*([^<]+?)\s*<\/span>/gi,
+  )) {
+    stats[m[2]!.toLowerCase().trim()] = m[1]!.replace(/,/g, "");
+  }
+  const beds = posInt(stats["beds"] ?? null);
+  const bathsTotal = posDec(stats["baths"] ?? null);
+  const garages = nonNegInt(stats["cars"] ?? null);
+  const stories = nonNegInt(stats["story"] ?? stats["stories"] ?? null);
+  const sqft = posInt(stats["sq. ft."] ?? stats["sqft"] ?? stats["sq ft"] ?? null);
+
+  // Status label.
+  const statusRaw = clean(html.match(/home-item-status-template[^>]*>([^<]+)</i)?.[1]);
+  // "NOW"/"SOLD" -> a real standing/closed home (MOVE_IN_READY); anything else
+  // (a future label) -> UNDER_CONSTRUCTION. No ready-date field is on the page.
+  const status: Status = statusRaw && /^(now|sold|move[- ]?in)/i.test(statusRaw)
+    ? "MOVE_IN_READY"
+    : "UNDER_CONSTRUCTION";
+
+  // homeType: a `unit-NN` final URL segment is a townhome/condo unit.
+  const homeType: HomeType = /\/unit-\d+\/?$/i.test(pageUrl) ? "TOWNHOME" : "SINGLE_FAMILY";
+
+  // Sales phone (builder-wide CTA number).
+  const ph = html.match(/\((\d{3})\)\s*(\d{3})-(\d{4})/);
+  const phone = ph ? `(${ph[1]}) ${ph[2]}-${ph[3]}` : null;
+
+  // Stable per-home id: the WordPress body-class postid. Anchor to the <body>
+  // class attribute — a `.postid-NNN` also appears inside CSS <style> rules, so
+  // a naive global match would grab the wrong id.
+  const postId = bodyClass.match(/\bpostid-(\d+)\b/)?.[1] ?? null;
+  const builderInventoryId = postId ? `postid-${postId}` : null;
+
+  return {
+    street,
+    city,
+    state,
+    zip,
+    community,
+    price,
+    priceRaw: priceMatch ? priceMatch[0] : priceRaw,
+    beds,
+    bathsTotal,
+    garages,
+    stories,
+    sqft,
+    status,
+    statusRaw,
+    homeType,
+    phone,
+    builderInventoryId,
+  };
+}
+
+export const brohnHomesAdapter: SourceAdapter = {
+  key: "brohn-homes-site",
+  version: "0.1.0",
+
+  async *fetch(ctx: FetchContext): AsyncIterable<RawPage> {
+    if (ctx.mode === "fixture") {
+      yield* fetchFixtures(ctx);
+      return;
+    }
+    const fetcher = new LiveFetcher(ctx.registry);
+    let homeUrls: string[] = [];
+    for (const sm of HOME_SITEMAPS) {
+      try {
+        const index = await fetcher.fetch(sm);
+        homeUrls.push(
+          ...[...index.body.toString("utf8").matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)]
+            .map((m) => m[1]!)
+            .filter((u) => HOME_URL_RE.test(u) && !u.includes("%")),
+        );
+      } catch (error) {
+        console.warn(`  skip sitemap ${sm}: ${error instanceof Error ? error.message : String(error)}`);
+      }
+    }
+    // dedupe, preserve order
+    homeUrls = [...new Set(homeUrls)];
+    if (METRO_FILTER.length) {
+      homeUrls = homeUrls.filter((u) => {
+        const metro = u.match(/\/homes\/[^/]+\/([^/]+)\//)?.[1]?.toLowerCase();
+        return metro ? METRO_FILTER.includes(metro) : false;
+      });
+    }
+    for (const url of homeUrls.slice(0, PAGE_LIMIT)) {
+      try {
+        yield await fetcher.fetch(url);
+      } catch (error) {
+        console.warn(`  skip ${url}: ${error instanceof Error ? error.message : String(error)}`);
+      }
+    }
+  },
+
+  extract(page: RawPage): ExtractionOutput {
+    try {
+      const html = page.body.toString("utf8");
+      const h = parseHome(html, page.url);
+      if (!h || !h.street) return { records: [], errors: [] }; // not a resolvable detail page
+
+      const cname = h.community ?? "Unknown";
+      const records: ExtractedRecord[] = [];
+
+      // Community FIRST — publish creates the FK target the home needs. No
+      // per-home geo is published; the sales phone is builder-wide so it is
+      // carried on the community as well as the home for convenience.
+      records.push({
+        entityType: "community",
+        canonicalHints: { builderSlug: BUILDER_SLUG, communityName: cname },
+        fields: {
+          name: fv(cname, cname, page.url),
+          street: fv<string>(null, null, page.url),
+          city: fv(h.city, h.city, page.url),
+          state: fv(h.state, h.state, page.url),
+          zip: fv(h.zip, h.zip, page.url),
+          county: fv<string>(null, null, page.url),
+          metro: fv<string>(null, null, page.url),
+          lat: fv<number>(null, null, page.url),
+          lon: fv<number>(null, null, page.url),
+          hoaFeeMonthly: fv<number>(null, null, page.url),
+          schoolDistrict: fv<string>(null, null, page.url),
+          ageRestricted: fv<boolean>(null, null, page.url),
+          salesPhone: fv(h.phone, h.phone, page.url),
+        },
+      });
+
+      records.push({
+        entityType: "inventory_home",
+        canonicalHints: {
+          builderSlug: BUILDER_SLUG,
+          communityName: cname,
+          address: h.street,
+          builderInventoryId: h.builderInventoryId ?? page.url,
+        },
+        fields: {
+          street: fv(h.street, h.street, page.url),
+          city: fv(h.city, h.city, page.url),
+          state: fv(h.state, h.state, page.url),
+          zip: fv(h.zip, h.zip, page.url),
+          price: fv(h.price, h.priceRaw, page.url, h.priceRaw ? `price ${h.priceRaw}` : null),
+          beds: fv(h.beds, h.beds != null ? String(h.beds) : null, page.url),
+          bathsTotal: fv(h.bathsTotal, h.bathsTotal != null ? String(h.bathsTotal) : null, page.url),
+          sqft: fv(h.sqft, h.sqft != null ? String(h.sqft) : null, page.url),
+          stories: fv(h.stories, h.stories != null ? String(h.stories) : null, page.url),
+          garageSpaces: fv(h.garages, h.garages != null ? String(h.garages) : null, page.url),
+          homeType: fv(h.homeType as never, null, page.url, `Brohn Homes ${h.homeType.toLowerCase()} inventory home`),
+          constructionStatus: fv(
+            h.status as never,
+            h.statusRaw,
+            page.url,
+            h.statusRaw ? `status "${h.statusRaw}"` : "no status label on page",
+          ),
+          estCompletionDate: fv<string>(null, null, page.url), // no ready-date field on the page
+          lotNumber: fv<string>(null, null, page.url),
+          builderInventoryId: fv(h.builderInventoryId, h.builderInventoryId, page.url),
+          // per-home geo NOT published by Brohn — left null (like richmond-american / smith-douglas)
+          lat: fv<number>(null, null, page.url),
+          lon: fv<number>(null, null, page.url),
+          planName: fv<string>(null, null, page.url), // no labelled plan name for the primary home
+          // Facts-only: images intentionally omitted (mediaRights=NONE).
+          images: fv<string[]>([], null, page.url),
+        },
+      });
+
+      return { records, errors: [] };
+    } catch (error) {
+      return { records: [], errors: [{ url: page.url, reason: String(error) }] };
+    }
+  },
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7094ba6f..41daa0a0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -215,6 +215,9 @@ importers:
       '@homesonspec/collector-beazer':
         specifier: workspace:*
         version: link:../../collectors/beazer
+      '@homesonspec/collector-brohn-homes':
+        specifier: workspace:*
+        version: link:../../collectors/brohn-homes
       '@homesonspec/collector-brookfield':
         specifier: workspace:*
         version: link:../../collectors/brookfield
@@ -245,6 +248,9 @@ importers:
       '@homesonspec/collector-gl-homes':
         specifier: workspace:*
         version: link:../../collectors/gl-homes
+      '@homesonspec/collector-goodall-homes':
+        specifier: workspace:*
+        version: link:../../collectors/goodall-homes
       '@homesonspec/collector-highland-homes':
         specifier: workspace:*
         version: link:../../collectors/highland-homes
@@ -278,6 +284,9 @@ importers:
       '@homesonspec/collector-mi-homes':
         specifier: workspace:*
         version: link:../../collectors/mi-homes
+      '@homesonspec/collector-oakwood-homes':
+        specifier: workspace:*
+        version: link:../../collectors/oakwood-homes
       '@homesonspec/collector-perry-homes':
         specifier: workspace:*
         version: link:../../collectors/perry-homes

← e10bc5b7 oakwood-homes: facts-only inventory adapter (Nuxt SSR + KOVA  ·  back to Homesonspec  ·  TK-10487: oakwood constructionStatus prefers authoritative K 4c9e0afc →