#!/usr/bin/env node
/**
 * build-worker-artifacts.mjs — **一個 Arcrun 實例會用到的每一顆 worker，都在這裡編**。
 *   這是 D49／D91 的那個「唯一官方編譯點」：成品放 `.worker-builds/`、commit 進 repo，
 *   任何人（安裝器、出貨線、self-host 使用者）要用零件都來這裡拿，不准自己編一份。
 *
 * 背景（Arcrun#80／arcrun-rag#39）：這個 repo 過去只把 tier1 零件（TinyGo→wasm）的成品
 *   commit 進 `.component-builds/{name}/component.wasm`；tier2（cypher-executor / kbdb /
 *   http_request / code / mcp 這五顆 TS worker）只有原始碼，沒有編好的成品。於是
 *   arcrun-rag 的安裝器只好自己在**它那邊**跑 esbuild（`installer/scripts/build-bundles.mjs`），
 *   結果同一份原始碼在不同機器編出不同位元組（見下「已知踩坑」），
 *   而且「這顆成品是哪個 commit 編的」只有整包一個 `source` 欄位，答不出單顆的來源。
 *
 * 🔴 2026-08-15（D91／Arcrun#125）本檔的涵蓋範圍從 5 顆擴到 23 顆，理由不是「順手多編幾顆」：
 *   · 只編 5 顆 ⇒ 下游 bundle 裡就只有 5 顆 ⇒ **「零件用到才下載」沒有貨可以下載**。
 *     而 `auth_static_key` 是每一支產品工作流解 `{{credential.X}}` 都會打的那一顆
 *     ⇒ 走網頁安裝器裝出來的實例，工作流一跑就 500（Arcrun#124）。
 *   · portal 前端（`arcrun-rag-ui`）當時仍由 arcrun-rag 自己拼裝
 *     ⇒ 使用者拿到的那顆 worker，沒有任何 Arcrun commit 說得出它的來源。
 *   ⇒ 現在三種東西走同一條路、同一份 manifest：引擎 worker／零件 worker／內嵌 UI worker。
 *
 * 本腳本要解的：
 *   1. 編譯只發生在這裡（Arcrun 本體），成品放固定位置 `.worker-builds/`，commit 進 repo——
 *      與 `.component-builds/*.wasm` 同一個既有慣例（self-host 用戶從 repo 直接拿部署來源）。
 *   2. 每顆成品自己記得「我是哪個 commit 編出來的」（`source_commit`，答到單顆目錄層級，
 *      不是整包一個欄位）。
 *   3. 同一個 commit、任何人任何時候編，位元組要相同——見下方兩個已知踩坑與對應解法。
 *
 * 已知踩坑（2026-08-10 實錄，docs-site/.../changelog.md 1.4.33 段；已回報 arcrun-rag#72）：
 *   ① esbuild 的 bundle 輸出會把「入口路徑」寫進產物內部的檔案邊界註解，而該路徑預設是
 *      **相對於 esbuild 執行時的 cwd**——雲端容器跑在 `.../arcrun/`、地端跑在
 *      `.../matrix/arcrun/`，同一份原始碼因此編出不同註解。
 *      解法：固定 `absWorkingDir` 為**本腳本自己算出的 repo 根目錄**（不吃外部 cwd／env
 *      路徑），entry 一律用「相對 repo 根目錄」的相對路徑餵給 esbuild——不管這個 clone
 *      實際被放在磁碟的哪個絕對路徑下，esbuild 內部算出的相對路徑字串都相同。
 *   ② 各 worker 目錄的 node_modules 若用不同套件管理器（pnpm store vs npm 平鋪）安裝，
 *      可能夾帶不同版本的間接依賴（實測 ajv/uri-js 差 1019 行）。
 *      解法：本腳本**不自己 npm/pnpm install**——強制要求呼叫者先用「該目錄既有的
 *      lockfile」（pnpm-lock.yaml 用 `pnpm install --frozen-lockfile`；package-lock.json
 *      用 `npm ci`）裝好 node_modules，並在建置前檢查 lockfile 是否存在，
 *      lockfile 是「同一份依賴圖」的機械保證，比信任「兩台機器裝出來一樣」牢靠。
 *
 * 用法：
 *   node scripts/build-worker-artifacts.mjs [--check-only]
 *   --check-only：只驗證每個 worker 的 node_modules 是否已按 lockfile 裝好，不編譯。
 *
 * 輸出：.worker-builds/<name>/worker.mjs (+ *.wasm)、.worker-builds/manifest.json
 */
import esbuild from 'esbuild';
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, rmSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, basename, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { createHash } from 'node:crypto';
// Arcrun#108 出貨閘（見 main() 內註解）。規則本體與掃描器住在 cypher-executor/scripts/。
import { scanProject as scanTenantSources } from '../cypher-executor/scripts/check-tenant-source.mjs';
// UI worker 的內嵌器（2026-08-15 從 arcrun-rag 搬回來，D91）。
import { buildUiWorker } from './build-ui-worker.mjs';

// REPO 一律用「本檔自己的位置」推導，不吃 cwd／env——這是踩坑①解法的地基：
// 不管這個 clone 被放在磁碟哪個絕對路徑，REPO 永遠是「這個 repo 的根目錄」，
// 下面所有 esbuild 呼叫都用「相對 REPO」的相對路徑，輸出字串才會與絕對路徑無關。
const REPO = resolve(fileURLToPath(new URL('.', import.meta.url)), '..');
const OUT = join(REPO, '.worker-builds');
const CHECK_ONLY = process.argv.includes('--check-only');

/** 引擎與工具 worker（各自的 dir/entry 不成規律，所以逐顆寫）。
 *  name 用 arcrun-rag 那邊的慣例（`arcrun-<kebab>`），方便安裝器直接對號。 */
const ENGINE_WORKERS = [
  { name: 'arcrun-cypher-executor', dir: 'cypher-executor', entry: 'src/index.ts', stripServices: true },
  { name: 'arcrun-kbdb', dir: 'kbdb', entry: 'src/index.ts' },
  { name: 'arcrun-code', dir: 'registry/components/code', entry: 'index.ts' },
  { name: 'arcrun-mcp', dir: 'mcp', entry: 'src/index.ts' },
];

/** 零件 worker 的家。底下每個子目錄＝一顆零件（TinyGo 編好的 `component.wasm`
 *  ＋一層 TS 外殼），格式一致，所以用**掃描**而不是手寫清單。 */
const COMPONENT_BUILDS = '.component-builds';

/**
 * 掃出所有零件 worker。
 *
 * 🔴 2026-08-15（Arcrun#125／D91）：這裡以前只手寫了 `http_request` **一顆**，
 *   其餘 18 顆零件從來沒有官方成品。後果不是「少編了幾顆」，是
 *   **「用到才下載」沒有貨可以下載**——arcrun-rag 的 bundle 只裝得到本檔編出來的東西，
 *   本檔沒編，公庫裡就永遠沒有它。而 `auth_static_key` 正是每一支產品工作流
 *   解 `{{credential.X}}` 都會打的那一顆 ⇒ 凡走網頁安裝器裝出來的實例，
 *   工作流一跑就 500（Arcrun#124 的 `error code: 1042`）。
 *
 * 🔴 **不准改回手寫清單**：手寫清單就是 arcrun-rag#27／D48 那個病的形狀
 *   ——「一份人維護的清單」與「磁碟上真的有什麼」必然漂移，而漂移的方向只有一個：
 *   新增的零件沒人記得加進去，於是它對使用者而言不存在。
 *   worker 名一律讀該零件自己的 `wrangler.toml`（那是它部署時真正會叫的名字），
 *   不由本檔從目錄名推導——推導＝再造一份會漂的真相。
 */
function discoverComponentWorkers() {
  const base = join(REPO, COMPONENT_BUILDS);
  if (!existsSync(base)) return { workers: [], excluded: [] };
  const workers = [];
  const excluded = [];
  for (const id of readdirSync(base).sort()) {
    const dir = join(COMPONENT_BUILDS, id);
    const abs = join(REPO, dir);
    if (!statSync(abs).isDirectory()) continue;
    const entry = 'src/index.ts';
    if (!existsSync(join(abs, entry))) continue;         // 不是一顆 worker（例如暫存目錄）
    const name = readWorkerName(join(abs, 'wrangler.toml'));
    if (!name) throw new Error(`${dir}/wrangler.toml 沒有 name＝這顆零件部署後會叫什麼名字沒人說得出來`);

    // 🔴 可出貨的判準＝**它的部署位元組在版控裡**，不是「這台機器的磁碟上有」。
    //   `.gitignore` 明文排除三顆（claude_api／km_writer／kbdb_upsert_block）——理由不是
    //   「還沒編」，是 DECISIONS §1「它們被錯做成零件，該降級成工作流／recipe」。
    //   本機磁碟上仍留著上次編的 wasm，所以「有沒有檔案」分辨不出這件事，「有沒有進版控」可以。
    //   ⇒ 用這個判準，未來任何一顆被判定不該出貨的零件，**不必回來改本檔**就會自動落在公庫外。
    if (!isTracked(join(dir, 'component.wasm'))) {
      excluded.push({ name, dir, reason: 'component.wasm 不在版控（.gitignore 明文排除＝這顆不是可出貨的零件）' });
      continue;
    }
    workers.push({ name, dir, entry, component: id });
  }
  return { workers, excluded };
}

/** 這個路徑有沒有被 git 追蹤（＝任何 clone 都拿得到）。 */
function isTracked(relPath) {
  try {
    const out = execSync(`git ls-files -- ${JSON.stringify(relPath)}`, { cwd: REPO, encoding: 'utf8' }).trim();
    return !!out;
  } catch {
    return false;
  }
}

/** 從 wrangler.toml 讀 `name = "..."`（worker 部署後的真名）。 */
function readWorkerName(tomlPath) {
  if (!existsSync(tomlPath)) return null;
  const m = readFileSync(tomlPath, 'utf8').match(/^\s*name\s*=\s*["']([^"']+)["']/m);
  return m ? m[1] : null;
}

/** 使用者實例的 GUI：沒有原始碼要打包，是把 `console-ui/public` 內嵌成單檔 worker。
 *  2026-08-15 從 arcrun-rag 搬回來（D91）——見 scripts/build-ui-worker.mjs 檔頭。 */
const UI_WORKER = {
  name: 'arcrun-rag-ui',
  dir: 'console-ui/public',
  builder: 'ui',
  // 這顆沒有 wrangler.toml（它不由 wrangler 部署，是安裝器用 CF API 上傳的），
  // 所以 compat 值寫在這裡。**沿用搬家前 arcrun-rag manifest 條目的原值**，
  // 讓這次搬家只改「誰產生它」，不改「它是什麼」。
  compat_date: '2026-07-01',
  compat_flags: [],
};

const { workers: COMPONENT_WORKERS, excluded: EXCLUDED_COMPONENTS } = discoverComponentWorkers();
const WORKERS = [...ENGINE_WORKERS, ...COMPONENT_WORKERS, UI_WORKER];

function sha256(buf) {
  return createHash('sha256').update(buf).digest('hex');
}

/** 檢查一個 worker 目錄的 node_modules 是否已按它自己的 lockfile 裝好（踩坑②的閘）。 */
function checkNodeModules(dir) {
  const abs = join(REPO, dir);
  const hasPnpmLock = existsSync(join(abs, 'pnpm-lock.yaml'));
  const hasNpmLock = existsSync(join(abs, 'package-lock.json'));
  if (!hasPnpmLock && !hasNpmLock) {
    return { ok: false, reason: `${dir} 沒有 pnpm-lock.yaml 也沒有 package-lock.json——依賴版本無法鎖定` };
  }
  if (!existsSync(join(abs, 'node_modules'))) {
    const cmd = hasPnpmLock ? 'pnpm install --frozen-lockfile' : 'npm ci';
    return { ok: false, reason: `${dir}/node_modules 不存在——先在該目錄跑：${cmd}` };
  }
  return { ok: true, via: hasPnpmLock ? 'pnpm (frozen)' : 'npm ci' };
}

/** 極簡 wrangler.toml 讀取（沿用 arcrun-rag build-bundles.mjs 同款邏輯，只抓需要的欄位）。 */
function readToml(tomlPath) {
  const t = existsSync(tomlPath) ? readFileSync(tomlPath, 'utf8') : '';
  const spec = { kv: [], d1: [], vectorize: [], ai: null, vars: {}, compat_flags: [], compat_date: null };
  const compatFlags = t.match(/compatibility_flags\s*=\s*\[([^\]]*)\]/);
  if (compatFlags) spec.compat_flags = [...compatFlags[1].matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
  const compatDate = t.match(/compatibility_date\s*=\s*["']([^"']+)["']/);
  if (compatDate) spec.compat_date = compatDate[1];
  for (const m of t.matchAll(/\[\[kv_namespaces\]\][\s\S]*?binding\s*=\s*["']([^"']+)["']/g)) spec.kv.push(m[1]);
  for (const m of t.matchAll(/\[\[d1_databases\]\]([\s\S]*?)(?=\n\[|\n*$)/g)) {
    const b = m[1].match(/binding\s*=\s*["']([^"']+)["']/);
    const n = m[1].match(/database_name\s*=\s*["']([^"']+)["']/);
    if (b) spec.d1.push({ binding: b[1], database_name: n ? n[1] : null });
  }
  for (const line of t.split('\n')) {
    if (/^\s*\[\[vectorize\]\]/.test(line)) spec.vectorize.push(true);
  }
  if (/^\s*\[ai\]/m.test(t)) spec.ai = true;
  const varsBlock = t.match(/\[vars\]([\s\S]*?)(?=\n\[|\n*$)/);
  if (varsBlock) for (const m of varsBlock[1].matchAll(/^\s*([A-Z0-9_]+)\s*=\s*["']([^"']*)["']/gm)) spec.vars[m[1]] = m[2];
  return spec;
}

/** esbuild plugin：.wasm import 攤平成同目錄檔名，記下要一起複製的 wasm part。 */
function wasmPlugin(wasmParts) {
  return {
    name: 'wasm-external',
    setup(build) {
      build.onResolve({ filter: /\.wasm$/ }, (args) => {
        const abs = resolve(args.resolveDir, args.path);
        const flat = basename(abs);
        if (!wasmParts.find((w) => w.part === flat)) wasmParts.push({ part: flat, abs });
        return { path: './' + flat, external: true };
      });
    },
  };
}

/** 每個 worker 自己的 source_commit：答到單顆目錄層級，不是整包一個欄位（Arcrun#80 的核心要求）。 */
function sourceCommitFor(dir) {
  try {
    const hash = execSync(`git log -1 --format=%H -- ${JSON.stringify(dir)}`, { cwd: REPO, encoding: 'utf8' }).trim();
    return hash || null;
  } catch {
    return null;
  }
}

function repoHead() {
  try {
    const sha = execSync('git rev-parse HEAD', { cwd: REPO, encoding: 'utf8' }).trim();
    // .worker-builds 是本腳本自己的輸出目錄——它在「寫出成品之前」永遠是 untracked，
    // 拿它判斷「原始碼乾不乾淨」是假陽性（自己把自己判成髒）。排除掉才是真正的
    // 「原始碼有沒有未 commit 的變更」。
    const dirty = execSync('git status --porcelain -- . ":(exclude).worker-builds"', { cwd: REPO, encoding: 'utf8' }).trim();
    return { sha, dirty: !!dirty };
  } catch {
    return { sha: null, dirty: null };
  }
}

/** UI worker：沒有原始碼要打包，是把 `console-ui/public` 內嵌成一顆單檔 worker。
 *  產出格式與 esbuild 那條一致（worker.mjs ＋ manifest 條目），下游因此不必分辨它是哪種。 */
async function buildUiOne(w) {
  const outDir = join(OUT, w.name);
  mkdirSync(outDir, { recursive: true });
  const { source, fingerprint, fileCount } = await buildUiWorker({ uiDir: join(REPO, w.dir) });
  const outFile = join(outDir, 'worker.mjs');
  writeFileSync(outFile, source);
  const jsBuf = readFileSync(outFile);
  return {
    name: w.name,
    source_dir: w.dir,
    source_commit: sourceCommitFor(w.dir),
    main_module: 'worker.mjs',
    main_file: `${w.name}/worker.mjs`,
    js_bytes: jsBuf.length,
    content_sha256: sha256(jsBuf),
    modules: [],
    compat_date: w.compat_date,
    compat_flags: w.compat_flags,
    // 內嵌 UI 不需要任何 binding；它只吃兩個由安裝器注入的 var
    // （WORKER_SUBDOMAIN 算 apiBase、ARCRUN_BUNDLE_VERSION 回報版本）。
    requires: { kv: [], d1: [], vectorize: 0, ai: false, vars: {} },
    // UI 自己的內容指紋——安裝器用它判斷「這台實例的前端要不要重推」。
    ui_fingerprint: fingerprint,
    ui_file_count: fileCount,
    warnings: [],
  };
}

async function buildOne(w) {
  if (w.builder === 'ui') return buildUiOne(w);
  const dirAbs = join(REPO, w.dir);
  const entryAbs = join(dirAbs, w.entry);
  if (!existsSync(entryAbs)) throw new Error(`entry 不存在: ${entryAbs}`);
  const outDir = join(OUT, w.name);
  mkdirSync(outDir, { recursive: true });

  // 踩坑①的解法核心：entry 用「相對 REPO」的路徑，absWorkingDir 固定為 REPO——
  // esbuild 內部產生的檔案邊界字串因此只依賴這個相對路徑，與這個 clone 實際被
  // 放在磁碟的哪個絕對路徑無關。
  const entryRel = relative(REPO, entryAbs);

  const wasmParts = [];
  const result = await esbuild.build({
    absWorkingDir: REPO,
    entryPoints: [entryRel],
    bundle: true,
    format: 'esm',
    platform: 'browser',
    target: 'es2022',
    outfile: relative(REPO, join(outDir, 'worker.mjs')),
    external: ['cloudflare:*', 'node:*'],
    plugins: [wasmPlugin(wasmParts)],
    logLevel: 'silent',
    metafile: true,
  });

  const modules = [];
  for (const wp of wasmParts) {
    if (!existsSync(wp.abs)) throw new Error(`wasm 找不到: ${wp.abs}（該 worker 需先 build/vendored wasm）`);
    copyFileSync(wp.abs, join(outDir, wp.part));
    modules.push({ name: wp.part, type: 'application/wasm', file: `${w.name}/${wp.part}`, sha256: sha256(readFileSync(wp.abs)) });
  }

  const spec = readToml(join(dirAbs, 'wrangler.toml'));
  const jsBuf = readFileSync(join(outDir, 'worker.mjs'));
  return {
    name: w.name,
    source_dir: w.dir,
    source_commit: sourceCommitFor(w.dir),
    main_module: 'worker.mjs',
    main_file: `${w.name}/worker.mjs`,
    js_bytes: jsBuf.length,
    content_sha256: sha256(jsBuf),
    modules,
    compat_date: spec.compat_date,
    compat_flags: spec.compat_flags,
    requires: {
      kv: spec.kv,
      d1: spec.d1,
      vectorize: spec.vectorize.length,
      ai: !!spec.ai,
      vars: spec.vars,
    },
    stripped: w.stripServices ? { services: 13 } : undefined,
    warnings: result.warnings.map((x) => x.text),
  };
}

async function main() {
  console.log(`REPO = ${REPO}`);

  // UI worker 沒有依賴要鎖（它內嵌的是靜態檔，不 import 任何套件）⇒ 不進這道閘。
  const precheck = WORKERS.filter((w) => w.builder !== 'ui').map((w) => ({ w, chk: checkNodeModules(w.dir) }));
  const failed = precheck.filter((p) => !p.chk.ok);
  if (failed.length) {
    console.error('\n❌ 建置中止：以下 worker 尚未按 lockfile 裝好依賴（踩坑②的閘）：\n');
    for (const f of failed) console.error(`   - ${f.chk.reason}`);
    console.error('\n這是刻意設計：本腳本不自己跑 install，避免「install 方式不同 → 依賴版本不同 → 位元組不同」。');
    process.exit(1);
  }
  if (EXCLUDED_COMPONENTS.length) {
    console.log(`\nℹ️ 刻意不進公庫的零件（${EXCLUDED_COMPONENTS.length}）：`);
    for (const e of EXCLUDED_COMPONENTS) console.log(`   - ${e.name}：${e.reason}`);
  }
  console.log('✔ node_modules 檢查通過：');
  for (const p of precheck) console.log(`   ${p.w.dir}  (${p.chk.via})`);

  // ── 出貨閘：靜態租戶字串不得用於資料面過濾（Arcrun#108，#105 同族）─────────────
  //
  // 為什麼擋在**這裡**：這條路徑是成品的產地（.worker-builds/ → 使用者的機器）。
  // 擋在這裡＝違規的碼**編不出成品、出不了貨**，而不是「有人記得跑檢查才會發現」。
  // leo 2026-08-12：「做一個平台要減少 hotfix。」規則存在但沒機制驗證，就是會再犯第三次。
  //
  // 規則本體是純函式（cypher-executor/scripts/tenant-source-rules.mjs），
  // 由 cypher-executor/tests/tenant-gate.test.ts 逐條驗「壞例子會擋、合法寫法零誤攔」
  // ——這道閘自己可測，也擋不到自己（掃描範圍只有 cypher-executor/src/）。
  const tenantViolations = scanTenantSources(join(REPO, 'cypher-executor'));
  if (tenantViolations.length) {
    console.error('\n❌ 建置中止：cypher-executor 有「靜態租戶字串用於資料面過濾」的寫法（Arcrun#108 的閘）：\n');
    for (const v of tenantViolations) {
      console.error(`   [${v.rule}] ${v.file}:${v.line}  ${v.text}`);
      console.error(`         → ${v.message}`);
    }
    console.error('\n知識資料面請用 knowledgeOwner(env) ＋ ownerQuery()/ownerField()');
    console.error('（cypher-executor/src/lib/tenant.ts 是租戶字串的唯一產地）。');
    console.error('本機自查：cd cypher-executor && npm run check:tenant\n');
    process.exit(1);
  }
  console.log('✔ 租戶來源檢查通過：cypher-executor 資料面 owner_id 全部來自 src/lib/tenant.ts');

  if (CHECK_ONLY) {
    console.log('\n--check-only：只驗證依賴就緒，不編譯。');
    return;
  }

  mkdirSync(OUT, { recursive: true });
  // 🔴 輸出目錄要**只**留這次編出來的東西——包含「上次編了、這次不該再編」的那些。
  //   不清掉就是棘輪（D48／arcrun-rag#27 的真兇）：東西一旦掉進成品目錄就永遠留著，
  //   沒有任何路徑會把它拿掉，於是「公庫裡有什麼」跟「本檔說該有什麼」慢慢分家。
  //   實撞（2026-08-15）：claude_api 被判定不該出貨後，它上一輪的成品仍留在 .worker-builds/。
  const want = new Set(WORKERS.map((w) => w.name));
  for (const name of readdirSync(OUT)) {
    if (name === 'manifest.json') continue;
    const d = join(OUT, name);
    if (!statSync(d).isDirectory()) continue;
    if (!want.has(name)) console.log(`   🧹 清掉不該在公庫裡的舊成品：${name}`);
    rmSync(d, { recursive: true, force: true });
  }

  const head = repoHead();
  const manifest = {
    schema: 1,
    built_for: 'arcrun-tier2-worker-artifacts',
    generated_at: new Date().toISOString(),
    repo_head: head.sha,
    repo_dirty: head.dirty,
    workers: [],
    // 被刻意排除在公庫之外的零件——**寫出來**，不要安靜跳過：
    // 下游（arcrun-rag 的懶載清單）看得到「這顆不在公庫、以及為什麼」，
    // 才不會像 2026-08-14 那樣把「沒有貨」誤讀成「機制壞了」。
    excluded: EXCLUDED_COMPONENTS,
    notes: [],
  };

  let failCount = 0;
  for (const w of WORKERS) {
    try {
      const entry = await buildOne(w);
      manifest.workers.push(entry);
      const wasmNote = entry.modules.length ? ` +${entry.modules.length} wasm` : '';
      console.log(`✔ ${w.name}  js=${(entry.js_bytes / 1024).toFixed(0)}KB  sha256=${entry.content_sha256.slice(0, 12)}  source=${(entry.source_commit || '').slice(0, 8)}${wasmNote}`);
    } catch (e) {
      manifest.notes.push(`FAILED ${w.name}: ${e.message}`);
      console.error(`✗ ${w.name}: ${e.message}`);
      failCount++;
    }
  }

  writeFileSync(join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
  console.log(`\nmanifest → ${join(OUT, 'manifest.json')} (${manifest.workers.length}/${WORKERS.length} built)`);
  if (failCount > 0 || head.dirty) {
    if (head.dirty) console.error('⚠️ 工作區不乾淨（有未 commit 的變更）——這份成品的 repo_head 標記不完全可信，僅供本地驗證用。');
    if (failCount > 0) process.exit(1);
  }
}
main().catch((e) => { console.error(e); process.exit(1); });
