/**
 * engine/projects.js — Project lifecycle (currently: comprehensive remove).
 *
 * Used by both the CLI (minions.js) and the dashboard (handleProjectsRemove)
 * so removal semantics stay identical regardless of entry point.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const dispatch = require('./dispatch');
const projectDiscovery = require('./project-discovery');
const { MINIONS_DIR } = shared;

function _httpError(status, message, extra) {
  const err = new Error(message);
  err.statusCode = status;
  if (extra) Object.assign(err, extra);
  return err;
}

function _sameProjectName(a, b) {
  return String(a || '').toLowerCase() === String(b || '').toLowerCase();
}

// Windows EPERM/EBUSY can hit fs.renameSync transiently when antivirus,
// indexer, or another scanner has the directory open. Retry with backoff
// before giving up — same pattern used by shared.safeWrite for atomic
// JSON writes (engine/shared.js:424).
function _renameWithRetry(src, dest) {
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      fs.renameSync(src, dest);
      return;
    } catch (e) {
      const transient = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'ENOTEMPTY';
      if (!transient || attempt === 4) throw e;
      shared.sleepMs(50 * (attempt + 1)); // 50, 100, 150, 200ms
    }
  }
}

function _projectRefMatches(projectRef, removedProject, projects) {
  if (!projectRef) return false;
  const refs = (projectRef && typeof projectRef === 'object')
    ? [projectRef.name, projectRef.localPath]
    : [projectRef];
  return refs.some(ref => {
    if (!ref) return false;
    const result = shared.resolveConfiguredProject(ref, projects);
    return !!(result.project && _sameProjectName(result.project.name, removedProject.name));
  });
}

function _workItemMatchesProject(item, removedProject, projects) {
  const refs = [
    item?.project,
    item?._project,
    item?.planProject,
    item?._planProject,
    item?._declaredPlanProject,
  ];
  return refs.some(ref => _projectRefMatches(ref, removedProject, projects));
}

function _isCentralDispatchSource(source) {
  return source === 'central-work-item' || source === 'central-work-item-fanout';
}

function _dispatchMatchesProject(d, removedProject, projects) {
  const meta = d?.meta || {};
  if (_isCentralDispatchSource(meta.source)) {
    return _workItemMatchesProject(meta.item, removedProject, projects) ||
      _projectRefMatches(meta.project, removedProject, projects);
  }
  return _workItemMatchesProject(meta.item, removedProject, projects) ||
    _projectRefMatches(meta.project, removedProject, projects);
}

function _centralDispatchDefaultedToProject(d, removedProject, projects) {
  const meta = d?.meta || {};
  return _isCentralDispatchSource(meta.source) &&
    meta.item?.id &&
    !_workItemMatchesProject(meta.item, removedProject, projects) &&
    _projectRefMatches(meta.project, removedProject, projects);
}

function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
  const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
  const ids = new Set();
  const state = shared.safeJson(dispatchPath) || {};
  for (const queue of ['pending', 'active']) {
    for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
      if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
    }
  }
  return ids;
}

function _requeueProjectlessCentralWorkItems(itemIds) {
  if (!itemIds || itemIds.size === 0) return 0;
  const wiPath = path.join(MINIONS_DIR, 'work-items.json');
  if (!fs.existsSync(wiPath)) return 0;
  let requeued = 0;
  shared.mutateWorkItems(wiPath, items => {
    if (!Array.isArray(items)) return items;
    for (const w of items) {
      if (!itemIds.has(w?.id) || w.status !== shared.WI_STATUS.DISPATCHED) continue;
      w.status = shared.WI_STATUS.PENDING;
      delete w.dispatched_at;
      delete w.dispatched_to;
      requeued++;
    }
    return items;
  });
  return requeued;
}

/**
 * Remove a project: cancel pending work items, drain dispatch + kill agents,
 * clean worktrees, disable project-targeted schedules, archive (or purge) the
 * data directory, then unlink from config.json.
 *
 * @param {string} target - Project name or localPath
 * @param {object} [options]
 * @param {'archive'|'keep'|'purge'} [options.dataMode='archive']
 *   archive: move projects/<name>/ to projects/.archived/<name>-YYYYMMDD/
 *   keep:    leave projects/<name>/ in place
 *   purge:   rm -rf projects/<name>/
 * @returns {object} summary { ok, project, cancelledItems, killedAgents,
 *   drainedDispatches, cleanedWorktrees, disabledSchedules, archivedTo,
 *   purgedDataDir, pipelineRefs[], warnings[] } or { ok:false, error }
 */
function removeProject(target, options = {}) {
  const dataMode = options.dataMode || (options.purge ? 'purge' : (options.keepData ? 'keep' : 'archive'));
  const summary = {
    ok: false,
    project: null,
    cancelledItems: 0,
    drainedDispatches: 0, // includes active dispatches whose agent processes were killed
    cleanedWorktrees: 0,
    disabledSchedules: 0,
    killedManagedProcesses: 0, // P-8a4d6f29 — managed-spawn cleanup
    archivedPlans: [],
    pipelineRefs: [],
    archivedTo: null,
    purgedDataDir: false,
    // P-bfa2d-remove-project-cleanup — soft-delete tag counts for entries in
    // the three central state files that survived the hard-cancel passes
    // (steps 1, 2). See step 7.5 for the orphan-tag policy rationale.
    orphanedRefs: { pullRequests: 0, workItems: 0, dispatches: 0 },
    warnings: [],
  };

  const configPath = path.join(MINIONS_DIR, 'config.json');
  let config;
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
  catch (e) { return { ...summary, error: 'Failed to read config: ' + e.message }; }

  const projects = shared.getProjects(config);
  const project = shared.resolveConfiguredProject(target, projects).project;
  if (!project) {
    const available = projects.map(p => p.name).join(', ') || '(none)';
    return { ...summary, error: `No project linked matching: ${target}. Available: ${available}` };
  }
  summary.project = { name: project.name, localPath: project.localPath };

  // 1. Cancel pending/queued work items linked to this project (project-local
  //    file + central). Done items are preserved as history.
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
    shared.projectWorkItemsPath(project),
    () => true,
    'project-removed',
  );
  summary.cancelledItems += dispatch.cancelPendingWorkItems(
    path.join(MINIONS_DIR, 'work-items.json'),
    w => _workItemMatchesProject(w, project, projects),
    'project-removed',
  );

  // 2. Drain dispatch — also kills active agent processes and unlinks pid +
  //    prompt sidecars in engine/tmp/, matching what plan delete does.
  const projectlessCentralItemIds = _collectProjectlessCentralDispatchItemIds(project, projects);
  summary.drainedDispatches = dispatch.cleanDispatchEntries(
    d => _dispatchMatchesProject(d, project, projects),
  );
  _requeueProjectlessCentralWorkItems(projectlessCentralItemIds);

  // 2.5. Managed-spawn cleanup (P-8a4d6f29). Centralised in managed-spawn.js
  //      so no other module needs to know about engine/managed-processes.json.
  //      Kills + unlinks every spec owned by this project, including the .log
  //      and .log.1 rotated sibling. Best-effort: failures only warn.
  try {
    const managedSpawn = require('./managed-spawn');
    const result = managedSpawn.removeManagedSpecsForProject(project.name);
    summary.killedManagedProcesses = result.killed || 0;
  } catch (e) {
    summary.warnings.push('managed-spawn cleanup: ' + e.message);
  }

  // 3. Clean up worktrees under this project's worktree root, honoring
  //    config.engine.worktreeRoot (mirrors lifecycle.js cleanupPlanWorktrees).
  if (project.localPath) {
    try {
      const wtRoot = path.resolve(project.localPath, config.engine?.worktreeRoot || '../worktrees');
      if (fs.existsSync(wtRoot)) {
        for (const dir of fs.readdirSync(wtRoot)) {
          try {
            if (shared.removeWorktree(path.join(wtRoot, dir), project.localPath, wtRoot)) {
              summary.cleanedWorktrees++;
            }
          } catch { /* best effort */ }
        }
      }
    } catch (e) { summary.warnings.push('worktree cleanup: ' + e.message); }
  }

  // 4. Schedule disables happen atomically with the project unlink in
  //    section 7's mutateJsonFileLocked callback so concurrent writers
  //    (addProject, dashboard settings update, pipeline schedule stage)
  //    don't clobber each other. The previous pattern mutated the unlocked
  //    in-memory `config` snapshot here and relied on the section 7 write
  //    to persist it — that lost concurrent updates that landed between
  //    the initial read and the write.

  // 5. Archive plans + PRDs targeting this project so they don't keep
  //    showing in the dashboard after removal. Three signals:
  //      a) PRD JSON `project` field matches → archive PRD + linked source plan
  //      b) plan .md content has `Project: <name>` (any markdown emphasis)
  //      c) plan .md filename contains the project name (case-insensitive)
  //    The .backup sidecar removal in archivePlan prevents engine restart
  //    from re-triggering plan completion (mirror of f28162b0 fix).
  const plansDir = path.join(MINIONS_DIR, 'plans');
  const plansArchive = path.join(plansDir, 'archive');
  const prdDir = path.join(MINIONS_DIR, 'prd');
  const escName = project.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const projectLineRe = new RegExp('^[*_>\\s]{0,4}\\*{0,2}_{0,2}Project:?\\*{0,2}_{0,2}\\s*' + escName + '\\b', 'im');
  const archivedSourcePlans = new Set();
  try {
    if (fs.existsSync(prdDir)) {
      const { archivePlan } = require('./lifecycle');
      for (const f of fs.readdirSync(prdDir).filter(f => f.endsWith('.json'))) {
        try {
          const prd = JSON.parse(fs.readFileSync(path.join(prdDir, f), 'utf8'));
          if (!_projectRefMatches(prd?.project, project, projects)) continue;
          archivePlan(f, prd, [project], config);
          summary.archivedPlans.push('prd/' + f);
          if (prd.source_plan) archivedSourcePlans.add(prd.source_plan);
        } catch { /* skip unreadable PRD */ }
      }
    }
  } catch (e) { summary.warnings.push('archive PRDs: ' + e.message); }
  try {
    if (fs.existsSync(plansDir)) {
      fs.mkdirSync(plansArchive, { recursive: true });
      const lowerName = project.name.toLowerCase();
      for (const f of fs.readdirSync(plansDir).filter(f => f.endsWith('.md'))) {
        if (archivedSourcePlans.has(f)) continue; // already moved by archivePlan above
        const fp = path.join(plansDir, f);
        try {
          const filenameMatch = f.toLowerCase().includes(lowerName);
          const content = filenameMatch ? '' : fs.readFileSync(fp, 'utf8');
          if (!filenameMatch && !projectLineRe.test(content)) continue;
          fs.renameSync(fp, path.join(plansArchive, f));
          summary.archivedPlans.push('plans/' + f);
        } catch { /* skip unreadable plan */ }
      }
    }
  } catch (e) { summary.warnings.push('archive plans: ' + e.message); }

  // 6. Surface pipelines that reference this project so the user can review
  //    them. Don't auto-modify — user intent there is unclear.
  try {
    const { getPipelines } = require('./pipeline');
    for (const p of getPipelines() || []) {
      const refs = [
        ...(p.monitoredResources || []),
        ...((p.stages || []).flatMap(s => s.monitoredResources || [])),
      ];
      if (refs.some(r => r && (
        _projectRefMatches(r.project, project, projects) ||
        _projectRefMatches(r._project, project, projects)
      ))) {
        summary.pipelineRefs.push(p.id);
      }
    }
  } catch { /* pipelines optional */ }

  // 7. Remove from config.json and persist schedule disables atomically
  //    under the config.json file lock. P-bf05-config-write-race —
  //    the prior `fs.writeFileSync` (and section 4's unlocked in-memory
  //    mutation) raced with addProject, dashboard settings updates, and
  //    pipeline schedule-stage writers, corrupting config.json on fresh
  //    bootstrap (which has no `.backup` self-heal). Both mutations now
  //    run inside the same mutateJsonFileLocked callback against the
  //    fresh on-disk snapshot.
  let writeError = null;
  try {
    shared.mutateJsonFileLocked(configPath, (cfg) => {
      if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return cfg;
      cfg.projects = (cfg.projects || []).filter(p => !_sameProjectName(p?.name, project.name));
      if (Array.isArray(cfg.schedules)) {
        for (const s of cfg.schedules) {
          if (_projectRefMatches(s.project, project, projects) && s.enabled !== false) {
            s.enabled = false;
            summary.disabledSchedules++;
          }
        }
      }
      return cfg;
    });
  } catch (e) { writeError = e; }
  if (writeError) return { ...summary, error: 'Failed to write config: ' + writeError.message };

  // 7.5. P-bfa2d-remove-project-cleanup — Tag dangling refs in the three
  //      CENTRAL state files (`pull-requests.json`, `work-items.json`,
  //      `engine/dispatch.json`) so downstream consumers can filter or sweep
  //      them. Project-local files in `projects/<name>/` are handled by
  //      step 8 (archive or purge), so this step only touches central state.
  //
  //      Policy is soft-delete (`_orphaned_at` + `_orphaned_project` tags)
  //      rather than hard-delete: preserves history for debugging, lets users
  //      see exactly which records were affected if a wrong project name was
  //      passed, and is reversible (strip the tags). Steps 1, 2 already
  //      hard-cancel/drain matching pending entries; this step only catches
  //      survivors (non-pending WIs, central PRs, defense-in-depth dispatch
  //      survivors).
  //
  //      Lock acquisition is sequential and uses the mutate-* helpers from
  //      engine/dispatch.js and engine/shared.js so we never hold two file
  //      locks simultaneously. Order: dispatch.json → pull-requests.json →
  //      work-items.json (alphabetical by basename). Each helper acquires
  //      its own lock, runs the synchronous mutator, then releases before
  //      the next helper runs.
  try {
    const orphanedAt = new Date().toISOString();
    const orphanedProject = project.name;

    // (a) engine/dispatch.json — defense-in-depth. cleanDispatchEntries in
    //     step 2 should have already removed every matching entry; tag any
    //     survivor so the operator can see the residue.
    try {
      dispatch.mutateDispatch((state) => {
        for (const queue of ['pending', 'active', 'completed']) {
          const arr = Array.isArray(state?.[queue]) ? state[queue] : [];
          for (const d of arr) {
            if (!d || d._orphaned_at) continue;
            if (!_dispatchMatchesProject(d, project, projects)) continue;
            d._orphaned_at = orphanedAt;
            d._orphaned_project = orphanedProject;
            summary.orphanedRefs.dispatches++;
          }
        }
        return state;
      });
    } catch (e) { summary.warnings.push('orphan-tag dispatch: ' + e.message); }

    // (b) MINIONS_DIR/pull-requests.json — central PR registry. PRs in this
    //     file lack an explicit project field; match by canonical scope
    //     (e.g. `github:owner/repo`) against the removed project's PR scope.
    const centralPrPath = shared.centralPullRequestsPath();
    if (fs.existsSync(centralPrPath)) {
      try {
        const projectScope = shared.getProjectPrScope(project);
        if (projectScope) {
          shared.mutatePullRequests(centralPrPath, (prs) => {
            for (const pr of prs) {
              if (!pr || pr._orphaned_at) continue;
              const canonical = shared.parseCanonicalPrId(pr.id);
              if (canonical?.scope !== projectScope) continue;
              pr._orphaned_at = orphanedAt;
              pr._orphaned_project = orphanedProject;
              summary.orphanedRefs.pullRequests++;
            }
            return prs;
          });
        }
      } catch (e) { summary.warnings.push('orphan-tag pull-requests: ' + e.message); }
    }

    // (c) MINIONS_DIR/work-items.json — central WI list. Pending/queued WIs
    //     were already CANCELLED by cancelPendingWorkItems in step 1; tag
    //     the remaining matchers (dispatched / done / failed / etc.) so
    //     downstream consumers can filter them.
    const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
    if (fs.existsSync(centralWiPath)) {
      try {
        shared.mutateWorkItems(centralWiPath, (items) => {
          for (const w of items) {
            if (!w || w._orphaned_at) continue;
            if (w.status === shared.WI_STATUS.CANCELLED) continue;
            if (!_workItemMatchesProject(w, project, projects)) continue;
            w._orphaned_at = orphanedAt;
            w._orphaned_project = orphanedProject;
            summary.orphanedRefs.workItems++;
          }
          return items;
        });
      } catch (e) { summary.warnings.push('orphan-tag work-items: ' + e.message); }
    }
  } catch (e) { summary.warnings.push('orphan-sweep: ' + e.message); }

  // 8. Move (or purge) projects/<name>/ — preserves PR/work-item history by
  //    default so a re-add can pick up where it left off.
  const dataDir = shared.projectStateDir(project);
  if (fs.existsSync(dataDir)) {
    if (dataMode === 'purge') {
      try { fs.rmSync(dataDir, { recursive: true, force: true }); summary.purgedDataDir = true; }
      catch (e) { summary.warnings.push('purge data dir: ' + e.message); }
    } else if (dataMode === 'archive') {
      try {
        const archiveRoot = path.join(MINIONS_DIR, 'projects', '.archived');
        fs.mkdirSync(archiveRoot, { recursive: true });
        const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
        let archived = path.join(archiveRoot, project.name + '-' + stamp);
        let n = 1;
        while (fs.existsSync(archived)) archived = path.join(archiveRoot, project.name + '-' + stamp + '-' + (++n));
        _renameWithRetry(dataDir, archived);
        summary.archivedTo = path.relative(MINIONS_DIR, archived).replace(/\\/g, '/');
      } catch (e) {
        // Surface the failure: callers (CLI, dashboard, tests) need to know
        // the data dir is still in place. Previously this was a silent warning
        // and `archivedTo` stayed null, making flakes look like missing-set
        // assertions instead of real archive failures.
        summary.archiveError = e.code ? `${e.code}: ${e.message}` : e.message;
        summary.warnings.push('archive data dir: ' + e.message);
      }
    }
  }

  // 9. Drop SQL rows for the project's scope so safeJson shim reads no longer
  //    surface the removed project after the JSON file is archived.
  try { require('./work-items-store').dropScope(project.name); } catch { /* best-effort */ }
  try { require('./pull-requests-store').dropScope(project.name); } catch { /* best-effort */ }

  summary.ok = true;
  return summary;
}

// ─── Project Add (W-mq8li79a000889fa) ────────────────────────────────────────
// Centralized project link helper used by both /api/projects/add (dashboard)
// and `minions add <path>` (CLI). Mirrors removeProject's "single source of
// truth" role so add semantics are identical regardless of entry point.
//
// Fail-closed contract for Azure DevOps projects:
//   - The linked entry MUST end up with a real repositoryId (GUID). The
//     poller, watches, and conflict-resolution surfaces all key on it; an
//     empty value silently breaks downstream automation.
//   - Resolution path is `az repos show` → `az repos list` → official ADO
//     REST endpoint (`/_apis/git/repositories/{repo}?api-version=7.1`).
//   - When ALL three paths fail to surface a GUID, throw a 4xx-shaped Error
//     and DO NOT persist a partial project entry to config.json. Callers
//     translate `err.statusCode` (default 400) + `err.message` to their
//     transport (HTTP body / CLI stderr).
//
// observeAuthors:
//   - Optional array of identifiers (UPNs, display names) the `ado-author-prs`
//     watch will surface PRs from. Stored on the project entry so the watch
//     can validate the project shape up-front instead of failing at evaluate
//     time. Non-ADO projects ignore this field (watch is ADO-only today).
//
// Concurrency:
//   - Config is mutated under mutateJsonFileLocked so a concurrent CLI add
//     and dashboard add cannot both pass the pre-check and double-insert.
function assertProjectLinkOk(project) {
  if (!project || typeof project !== 'object') {
    throw _httpError(400, 'Invalid project entry: must be an object');
  }
  const host = String(project.repoHost || 'github').toLowerCase();
  if (host !== 'ado') return; // GitHub / generic projects have no required GUID

  const missing = [];
  if (!String(project.repositoryId || '').trim()) missing.push('repositoryId');
  if (!String(project.adoOrg || '').trim()) missing.push('adoOrg');
  if (!String(project.adoProject || '').trim()) missing.push('adoProject');
  if (!String(project.repoName || '').trim()) missing.push('repoName');
  if (missing.length === 0) return;

  const label = project.name || project.localPath || '<unknown>';
  const hint = missing.includes('repositoryId')
    ? ' Resolve via Azure CLI (`az login` + `az repos show`) or by linking from a host where the ADO REST API is reachable.'
    : '';
  throw _httpError(400,
    `ADO project "${label}" missing required field(s): ${missing.join(', ')}.${hint}`,
    { missing, code: 'PROJECT_LINK_INVALID_ADO' });
}

async function addProject(target, options = {}) {
  const inputPath = typeof target === 'string' ? target : (target && target.path);
  if (!inputPath || typeof inputPath !== 'string') {
    throw _httpError(400, 'addProject: target path required');
  }

  // 1. Validate path (must exist + be a git repo OR caller opts in via
  //    allowNonRepo + confirmToken — same contract as the dashboard handler).
  const resolved = shared.validateProjectPath(inputPath, {
    allowNonRepo: options.allowNonRepo === true,
    confirmToken: options.confirmToken,
    isValidToken: options.isValidToken,
  });

  // 2. Pre-flight duplicate check OUTSIDE the lock so the common case
  //    (already-linked) returns a clean 400 without churning the config file.
  //    Resolve config path through `shared.MINIONS_DIR` (fresh on each call)
  //    so tests that swap MINIONS_DIR via createTestMinionsDir() see the
  //    right path even when projects.js itself isn't isolated.
  const configPath = path.join(shared.MINIONS_DIR, 'config.json');
  const existingConfig = shared.safeJson(configPath) || { projects: [] };
  const existingProjects = Array.isArray(existingConfig.projects) ? existingConfig.projects : [];
  if (existingProjects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
    throw _httpError(400, `Project already linked at ${resolved}`);
  }

  // 3. Discover metadata. Async path includes the official ADO REST fallback
  //    for repositoryId when `az` CLI isn't available — see
  //    project-discovery.js#discoverProjectMetadataAsync.
  const detected = await projectDiscovery.discoverProjectMetadataAsync(resolved, options);
  if (!detected.name) detected.name = path.basename(resolved);

  // 4. Validate name (rejects shell metacharacters, path separators, …).
  const rawName = options.name || detected.name;
  const name = shared.validateProjectName(rawName);

  // 5. Build entry.
  const project = projectDiscovery.buildProjectEntry({
    name,
    description: options.description !== undefined ? options.description : (detected.description || ''),
    localPath: resolved,
    repoHost: detected.repoHost || 'github',
    repositoryId: detected.repositoryId || '',
    org: detected.org || '',
    project: detected.project || '',
    repoName: detected.repoName || name,
    mainBranch: detected.mainBranch || 'main',
    prUrlBase: detected.prUrlBase,
    worktreeMode: options.worktreeMode,
    observeAuthors: Array.isArray(options.observeAuthors) ? options.observeAuthors : undefined,
  });

  // 6. Fail-closed gate. AFTER buildProjectEntry — buildProjectEntry never
  //    re-resolves metadata, so this check sees the same shape the engine
  //    will. Throws statusCode=400 with `missing[]` populated.
  assertProjectLinkOk(project);

  // 7. Ensure project state files (projects/<name>/...).
  shared.ensureProjectStateFiles(project);

  // 8. Persist under config lock. Re-check inside the lock so a concurrent
  //    add can't insert a duplicate between our pre-check and the write.
  let duplicate = false;
  shared.mutateJsonFileLocked(configPath, (config) => {
    if (!config || typeof config !== 'object' || Array.isArray(config)) config = { projects: [], agents: {}, engine: {} };
    if (!Array.isArray(config.projects)) config.projects = [];
    if (config.projects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
      duplicate = true;
      return config;
    }
    config.projects.push(project);
    return config;
  }, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });

  if (duplicate) throw _httpError(400, `Project already linked at ${resolved}`);

  return { ok: true, project, detected, path: resolved };
}

module.exports.assertProjectLinkOk = assertProjectLinkOk;
module.exports.addProject = addProject;

module.exports.removeProject = removeProject;
