@yemi33/minions 0.1.2164 → 0.1.2166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dashboard.js CHANGED
@@ -7861,84 +7861,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7861
7861
  const body = await readBody(req);
7862
7862
  if (!body.path) return jsonReply(res, 400, { error: 'path required' });
7863
7863
 
7864
- // SEC-05: validate path (must be a git repo, unless caller supplies
7865
- // allowNonRepo + a valid single-use confirmation token). Runs BEFORE any
7866
- // mutation of config.json so a rejected path leaves no side effects.
7867
- let target;
7864
+ // W-mq8li79a000889fa: route through projects.addProject so dashboard
7865
+ // and CLI share the same SEC-validate + auto-discover + REST-fallback
7866
+ // + fail-closed gate (rejects ADO links missing repositoryId).
7867
+ const { addProject } = require('./engine/projects');
7868
+ let result;
7868
7869
  try {
7869
- target = shared.validateProjectPath(body.path, {
7870
+ result = await addProject(body.path, {
7870
7871
  allowNonRepo: body.allowNonRepo === true,
7871
7872
  confirmToken: body.confirmToken,
7872
7873
  isValidToken: _consumeProjectConfirmToken,
7874
+ name: body.name,
7875
+ worktreeMode: body.worktreeMode,
7876
+ observeAuthors: Array.isArray(body.observeAuthors) ? body.observeAuthors : undefined,
7873
7877
  });
7874
7878
  } catch (e) {
7875
7879
  return jsonReply(res, e.statusCode || 400, {
7876
7880
  error: e.message,
7877
7881
  ...(e.needsConfirmation ? { needsConfirmation: true } : {}),
7882
+ ...(e.missing ? { missing: e.missing } : {}),
7883
+ ...(e.code ? { code: e.code } : {}),
7878
7884
  });
7879
7885
  }
7880
7886
 
7881
- // Check if already linked under the config lock so concurrent dashboard
7882
- // adds cannot both pass the preflight check and then clobber config.json.
7883
- let alreadyLinked = false;
7884
- mutateDashboardConfig(config => {
7885
- if (!Array.isArray(config.projects)) config.projects = [];
7886
- alreadyLinked = config.projects.some(p => shared.sameResolvedPath(p.localPath, target));
7887
- return config;
7888
- });
7889
- if (alreadyLinked) {
7890
- return jsonReply(res, 400, { error: 'Project already linked at ' + target });
7891
- }
7892
-
7893
- // Auto-discover from git repo. Shared with minions.js so CLI and dashboard
7894
- // handle ADO URL variants and repository GUID enrichment consistently.
7895
- const detected = projectDiscovery.discoverProjectMetadata(target);
7896
- if (!detected.name) detected.name = path.basename(target);
7897
- const description = detected.description || '';
7898
-
7899
- const rawName = body.name || detected.name;
7900
-
7901
- // SEC-04: validate project name — rejects path traversal, shell
7902
- // metacharacters, whitespace, overly long names. Runs BEFORE any
7903
- // mutation of config.json. Auto-detected names (from package.json /
7904
- // directory basename) also go through this check so a maliciously
7905
- // named repo on disk can't inject metacharacters either.
7906
- let name;
7907
- try {
7908
- name = shared.validateProjectName(rawName);
7909
- } catch (e) {
7910
- return jsonReply(res, e.statusCode || 400, { error: e.message });
7911
- }
7912
-
7913
- const project = projectDiscovery.buildProjectEntry({
7914
- name, description, localPath: target,
7915
- repoHost: detected.repoHost || 'github',
7916
- repositoryId: detected.repositoryId || '',
7917
- org: detected.org || '',
7918
- project: detected.project || '',
7919
- repoName: detected.repoName || name,
7920
- mainBranch: detected.mainBranch || 'main',
7921
- prUrlBase: detected.prUrlBase,
7922
- // P-a3f9b201: thread worktreeMode from POST body. buildProjectEntry
7923
- // validates via shared.validateWorktreeMode — unknown values bubble
7924
- // up as HTTP 400 below.
7925
- worktreeMode: body.worktreeMode,
7926
- });
7927
-
7928
- // Create centralized project state files.
7929
- shared.ensureProjectStateFiles(project);
7930
-
7931
- let duplicate = false;
7932
- mutateDashboardConfig(config => {
7933
- if (!Array.isArray(config.projects)) config.projects = [];
7934
- if (config.projects.some(p => shared.sameResolvedPath(p.localPath, target))) {
7935
- duplicate = true;
7936
- return config;
7937
- }
7938
- config.projects.push(project);
7939
- return config;
7940
- });
7941
- if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
7942
7887
  reloadConfig(); // Update in-memory project list immediately
7943
7888
  warmProjectGitStatusCache(); // Probe the new project's git status in the background
7944
7889
  // includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
@@ -7946,7 +7891,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7946
7891
  // up to a minute after the add. Matches handleProjectsRemove's behavior.
7947
7892
  invalidateStatusCache({ includeSlow: true });
7948
7893
 
7949
- return jsonReply(res, 200, { ok: true, name, path: target, detected });
7894
+ return jsonReply(res, 200, {
7895
+ ok: true,
7896
+ name: result.project.name,
7897
+ path: result.path,
7898
+ detected: result.detected,
7899
+ });
7950
7900
  } catch (e) { return jsonReply(res, 400, { error: e.message }); }
7951
7901
  }
7952
7902
 
@@ -0,0 +1,69 @@
1
+ # Deprecation tracker — schema and audit process
2
+
3
+ Source of truth: [`docs/deprecated.json`](./deprecated.json) — array of deprecation entries the `cleanup-deprecated` skill walks weekly.
4
+
5
+ Each entry documents one deprecated symbol / config field / endpoint, why it
6
+ exists, when (and under what signal) it is safe to remove, and which tests
7
+ pin its contract until the removal lands.
8
+
9
+ ## Common fields
10
+
11
+ The schema is loose — entries grow fields as their gating story matures. The
12
+ following are all optional unless called out as required.
13
+
14
+ - `id` — **required**, kebab-case unique identifier (e.g. `completion-fallback-parsers`).
15
+ - `description` / `reason` — free-form prose explaining what is deprecated and why.
16
+ - `file` / `location` / `code` — pointer(s) to the deprecated symbol(s). `code` is
17
+ the structured form: an array of `{ file, lines, note }` objects.
18
+ - `lines` — comma-separated string of line numbers (drift-prone; cross-check before
19
+ acting).
20
+ - `deprecated` / `targetRemovalDate` — calendar dates (YYYY-MM-DD) marking when
21
+ the entry was registered and when it may be removed. `targetRemovalDate: null`
22
+ signals a non-calendar gate (telemetry / signal-based).
23
+ - `removalGate` — prose describing the signal that must clear before removal.
24
+ - `notes` — free-form post-removal scope or rollout caveats.
25
+
26
+ ## Counter-gated entries (telemetry sentinels)
27
+
28
+ When the removal gate is "this counter must read 0 across a sweep window," the
29
+ entry uses these fields:
30
+
31
+ - `telemetryGate` — string referencing the metrics counter that must read zero.
32
+ - `sweepWindowDays` — positive integer (window length in days).
33
+ - `sweepStartDate` — `YYYY-MM-DD` window start.
34
+ - `enforcingTest` / `enforcingSweepWindowTest` — paths to the unit tests that
35
+ pin the gate contract.
36
+
37
+ ## Static call-site audit fields (P-c5d9e7b4)
38
+
39
+ Counter-gated entries also opt in to the static call-site audit
40
+ (`test/unit/deprecated-call-site-audit.test.js`). The audit enumerates live
41
+ call sites of the entry's symbols across `engine/`, `dashboard/`, and the
42
+ root-level `.js` files, then asserts every discovered call site is declared
43
+ in either `removalSites` or `allowedCallers`.
44
+
45
+ - `symbols` — **required for the audit to run**. Array of bare identifier
46
+ strings (e.g. `["parseStructuredCompletion", "parseCompletionFieldSummary"]`).
47
+ The audit builds a `\b<symbol>\s*\(` regex per name and skips matches that
48
+ sit immediately after the `function` keyword (i.e. it does not count the
49
+ definition itself as a caller). If `symbols` is missing or empty, the audit
50
+ fails the entry — there is no fallback parse of `description`.
51
+ - `removalSites` — array of `{ file, line, symbol?, reason? }` objects listing
52
+ the call sites that WILL be removed when the deprecation gate clears (the
53
+ "expected, planned" callers). Paths are forward-slash relative to the repo
54
+ root.
55
+ - `allowedCallers` — array of `{ site, reason }` objects (or
56
+ `{ file, line, reason }` — both shapes are accepted) listing call sites that
57
+ are intentionally preserved past the gate (e.g. a sibling deprecation that
58
+ will retire separately). Defaults to `[]` when absent.
59
+
60
+ The audit assertion is `discoveredCallers ⊆ (removalSites ∪ allowedCallers)`.
61
+ On failure, the assertion message names every unexpected `file:line` so the
62
+ audit author can immediately classify each one as a new `removalSites` entry
63
+ or a new `allowedCallers` entry.
64
+
65
+ Entries WITHOUT a `telemetryGate` field are skipped by the audit — calendar-
66
+ gated entries (e.g. `qa-json-sidecars`) and signal-gated entries that don't
67
+ hang on a counter (e.g. `config-claude-binary-override`) don't carry the
68
+ `symbols` / `removalSites` / `allowedCallers` triple and are not enforced by
69
+ the static audit. Add them later if the gate evolves into a counter sentinel.
@@ -34,7 +34,14 @@
34
34
  "sweepRationale": "14 days matches the dead-code audit cadence (one full weekly audit cycle plus a buffer week). Window restarted on 2026-06-11 (= merge_date(P-a7b2c1d9) + 1 day, anchor commit eb544aa80dbd64c87b78ad08a17ca3560e627094 merged 2026-06-10T18:04:12Z) because the original 2026-05-27 window was invalidated: the engine/timeout.js caller of parseStructuredCompletion (commit 0f4b9787, 2026-04-30) predated the telemetry counter (commit cea61119, 2026-05-13), so the hidden caller was never instrumented and a zero reading proved nothing. P-a7b2c1d9 removed that hidden caller; the new window measures only callers that the counter actually instruments. Policy decision pending confirmation in open-question #1 of the 2026-05-27 Bug Audit Review meeting conclusion — if the human teammate prefers a different cadence, repin this field and the matching enforcingSweepWindowTest fixture.",
35
35
  "enforcingTest": "test/unit/completion-fallback-telemetry.test.js:217-234",
36
36
  "enforcingSweepWindowTest": "test/unit/completion-fallback-sweep-window.test.js",
37
- "notes": "Do NOT set removedAt until telemetry confirms zero usage across the sweepWindowDays from sweepStartDate. The follow-up code-removal PR (dropping parseStructuredCompletion at engine/lifecycle.js:3410, parseCompletionFieldSummary at :3608, and the gated fallback at :4675-4700) is dispatched separately once the window is observed clean."
37
+ "symbols": ["parseStructuredCompletion", "parseCompletionFieldSummary"],
38
+ "removalSites": [
39
+ { "file": "engine/lifecycle.js", "line": 4675, "symbol": "parseStructuredCompletion", "reason": "Gated fallback at engine/lifecycle.js:4675-4700 — removed in the same follow-up PR that drops the parser definitions." },
40
+ { "file": "engine/lifecycle.js", "line": 4676, "symbol": "parseCompletionFieldSummary", "reason": "Gated fallback at engine/lifecycle.js:4675-4700 — removed in the same follow-up PR that drops the parser definitions." }
41
+ ],
42
+ "allowedCallers": [],
43
+ "schemaRef": "docs/deprecated-process.md (see 'Static call-site audit fields')",
44
+ "notes": "Do NOT set removedAt until telemetry confirms zero usage across the sweepWindowDays from sweepStartDate. The follow-up code-removal PR (dropping parseStructuredCompletion at engine/lifecycle.js:3410, parseCompletionFieldSummary at :3608, and the gated fallback at :4675-4700) is dispatched separately once the window is observed clean. The symbols/removalSites/allowedCallers triple is enforced by test/unit/deprecated-call-site-audit.test.js — see docs/deprecated-process.md for the schema."
38
45
  },
39
46
  {
40
47
  "id": "config-claude-binary-override",
@@ -195,6 +195,61 @@ function runAzJson(execFileSync, args, timeoutMs) {
195
195
  }));
196
196
  }
197
197
 
198
+ // W-mq8li79a000889fa — official ADO REST endpoint for repo lookup.
199
+ // Matches engine/ado.js#resolveAdoBuildRepositoryGuid (the canonical
200
+ // PR-poll path). Path-style encoding mirrors that helper so a project
201
+ // named with reserved chars (e.g. "Project With Spaces") routes the
202
+ // same way at link time as at poll time. The repo segment is URL-
203
+ // encoded — ADO accepts either the repo GUID or repo name here.
204
+ function _buildAdoRepoLookupUrl({ orgUrl, project, repoName }) {
205
+ return `${orgUrl}/${encodeURIComponent(project)}/_apis/git/repositories/${encodeURIComponent(repoName)}?api-version=7.1`;
206
+ }
207
+
208
+ // W-mq8li79a000889fa — REST-based GUID resolver. Returns the canonical
209
+ // repo metadata when ADO responds 200 + JSON; returns null on any
210
+ // failure (auth, network, 404, HTML redirect). Fail-closed semantics
211
+ // are the caller's responsibility — discoverProjectMetadataAsync calls
212
+ // this AFTER `az repos show/list` to fill in the GUID when the CLI is
213
+ // unavailable (typical agent box without Azure CLI installed).
214
+ //
215
+ // Token acquisition uses engine/ado-token.js#acquireAdoToken (az CLI
216
+ // then azureauth fallback). Tests inject `options.fetch` and
217
+ // `options.acquireAdoToken` directly.
218
+ async function resolveAdoRemoteMetadataViaRest(remote, options = {}) {
219
+ if (!remote || !remote.orgUrl || !remote.project || !remote.repoName) return null;
220
+ const fetchImpl = options.fetch || globalThis.fetch;
221
+ if (typeof fetchImpl !== 'function') return null;
222
+ let acquireToken = options.acquireAdoToken;
223
+ if (typeof acquireToken !== 'function') {
224
+ try { acquireToken = require('./ado-token').acquireAdoToken; }
225
+ catch { return null; }
226
+ }
227
+ let token;
228
+ try {
229
+ const result = await acquireToken({});
230
+ token = result && result.token;
231
+ } catch { return null; }
232
+ if (!token) return null;
233
+
234
+ const url = _buildAdoRepoLookupUrl(remote);
235
+ let res;
236
+ try {
237
+ res = await fetchImpl(url, {
238
+ method: 'GET',
239
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
240
+ });
241
+ } catch { return null; }
242
+ if (!res || !res.ok) return null;
243
+ let text;
244
+ try { text = await res.text(); } catch { return null; }
245
+ if (!text || text.trimStart().startsWith('<')) return null;
246
+ let data;
247
+ try { data = JSON.parse(text); } catch { return null; }
248
+ const normalized = normalizeAzRepoResult(data, remote);
249
+ if (!normalized || !String(normalized.repositoryId || '').trim()) return null;
250
+ return normalized;
251
+ }
252
+
198
253
  function resolveAdoRemoteMetadata(remote, options = {}) {
199
254
  if (!remote) return null;
200
255
  const execFileSync = options.execFileSync || defaultExecFileSync;
@@ -330,6 +385,42 @@ function discoverProjectMetadata(targetDir, options = {}) {
330
385
  return result;
331
386
  }
332
387
 
388
+ // W-mq8li79a000889fa — async wrapper that runs the sync `az`-based
389
+ // discovery first, then falls back to the official ADO REST endpoint
390
+ // (via engine/ado-token.js + globalThis.fetch) when the remote is ADO
391
+ // but no repositoryId was resolved. Non-ADO remotes are returned
392
+ // unchanged. Failure to resolve the GUID via REST leaves
393
+ // `repositoryId` empty — callers (engine/projects.js#addProject) MUST
394
+ // fail closed if the linked project ends up without a GUID.
395
+ async function discoverProjectMetadataAsync(targetDir, options = {}) {
396
+ const detected = discoverProjectMetadata(targetDir, options);
397
+ if (!detected || detected.repoHost !== 'ado') return detected;
398
+ if (String(detected.repositoryId || '').trim()) return detected;
399
+ // ADO remote without GUID — try official REST endpoint as a fallback.
400
+ // discoverProjectMetadata always populates org/orgUrl/project/repoName
401
+ // for ADO remotes (via parseAdoRemoteUrl), so this is safe to call.
402
+ const parsedRemote = {
403
+ org: detected.org,
404
+ orgUrl: detected.orgUrl,
405
+ project: detected.project,
406
+ repoName: detected.repoName,
407
+ repoHost: 'ado',
408
+ remoteUrl: detected.remoteUrl,
409
+ prUrlBase: detected.prUrlBase,
410
+ collection: detected.collection || '',
411
+ repositoryId: '',
412
+ };
413
+ const restMeta = await resolveAdoRemoteMetadataViaRest(parsedRemote, options);
414
+ if (restMeta && String(restMeta.repositoryId || '').trim()) {
415
+ Object.assign(detected, restMeta);
416
+ if (!Array.isArray(detected._found)) detected._found = [];
417
+ if (!detected._found.includes('Azure DevOps repository metadata (REST)')) {
418
+ detected._found.push('Azure DevOps repository metadata (REST)');
419
+ }
420
+ }
421
+ return detected;
422
+ }
423
+
333
424
  function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
334
425
  if (prUrlBase) return prUrlBase;
335
426
  if (repoHost === 'github') {
@@ -341,7 +432,7 @@ function buildPrUrlBase({ repoHost, org, project, repoName, prUrlBase }) {
341
432
  return '';
342
433
  }
343
434
 
344
- function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode }) {
435
+ function buildProjectEntry({ name, description, localPath, repoHost, repositoryId, org, project, repoName, mainBranch, prUrlBase, worktreeMode, observeAuthors }) {
345
436
  const safeName = (name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'project';
346
437
  const host = repoHost || 'github';
347
438
  const isAdo = host === 'ado';
@@ -368,6 +459,17 @@ function buildProjectEntry({ name, description, localPath, repoHost, repositoryI
368
459
  },
369
460
  };
370
461
  if (resolvedWorktreeMode !== undefined) entry.worktreeMode = resolvedWorktreeMode;
462
+ // W-mq8li79a000889fa — observeAuthors is the canonical list of author
463
+ // identifiers consumed by the `ado-author-prs` watch (and any future
464
+ // author-scoped watch). Currently only meaningful for ADO projects —
465
+ // the GitHub flow uses watch targets directly. Stored as an array of
466
+ // strings; only persisted when explicitly supplied so existing
467
+ // projects don't gain an empty field on rewrites.
468
+ if (Array.isArray(observeAuthors) && observeAuthors.length > 0) {
469
+ entry.observeAuthors = observeAuthors
470
+ .map(v => String(v || '').trim())
471
+ .filter(Boolean);
472
+ }
371
473
  return entry;
372
474
  }
373
475
 
@@ -391,7 +493,9 @@ module.exports = {
391
493
  parseAdoRemoteUrl,
392
494
  parseGitHubRemoteUrl,
393
495
  resolveAdoRemoteMetadata,
496
+ resolveAdoRemoteMetadataViaRest,
394
497
  discoverProjectMetadata,
498
+ discoverProjectMetadataAsync,
395
499
  buildPrUrlBase,
396
500
  buildProjectEntry,
397
501
  buildScanResult,
@@ -9,8 +9,16 @@ const fs = require('fs');
9
9
  const path = require('path');
10
10
  const shared = require('./shared');
11
11
  const dispatch = require('./dispatch');
12
+ const projectDiscovery = require('./project-discovery');
12
13
  const { MINIONS_DIR } = shared;
13
14
 
15
+ function _httpError(status, message, extra) {
16
+ const err = new Error(message);
17
+ err.statusCode = status;
18
+ if (extra) Object.assign(err, extra);
19
+ return err;
20
+ }
21
+
14
22
  function _sameProjectName(a, b) {
15
23
  return String(a || '').toLowerCase() === String(b || '').toLowerCase();
16
24
  }
@@ -429,4 +437,134 @@ function removeProject(target, options = {}) {
429
437
  return summary;
430
438
  }
431
439
 
432
- module.exports = { removeProject };
440
+ // ─── Project Add (W-mq8li79a000889fa) ────────────────────────────────────────
441
+ // Centralized project link helper used by both /api/projects/add (dashboard)
442
+ // and `minions add <path>` (CLI). Mirrors removeProject's "single source of
443
+ // truth" role so add semantics are identical regardless of entry point.
444
+ //
445
+ // Fail-closed contract for Azure DevOps projects:
446
+ // - The linked entry MUST end up with a real repositoryId (GUID). The
447
+ // poller, watches, and conflict-resolution surfaces all key on it; an
448
+ // empty value silently breaks downstream automation.
449
+ // - Resolution path is `az repos show` → `az repos list` → official ADO
450
+ // REST endpoint (`/_apis/git/repositories/{repo}?api-version=7.1`).
451
+ // - When ALL three paths fail to surface a GUID, throw a 4xx-shaped Error
452
+ // and DO NOT persist a partial project entry to config.json. Callers
453
+ // translate `err.statusCode` (default 400) + `err.message` to their
454
+ // transport (HTTP body / CLI stderr).
455
+ //
456
+ // observeAuthors:
457
+ // - Optional array of identifiers (UPNs, display names) the `ado-author-prs`
458
+ // watch will surface PRs from. Stored on the project entry so the watch
459
+ // can validate the project shape up-front instead of failing at evaluate
460
+ // time. Non-ADO projects ignore this field (watch is ADO-only today).
461
+ //
462
+ // Concurrency:
463
+ // - Config is mutated under mutateJsonFileLocked so a concurrent CLI add
464
+ // and dashboard add cannot both pass the pre-check and double-insert.
465
+ function assertProjectLinkOk(project) {
466
+ if (!project || typeof project !== 'object') {
467
+ throw _httpError(400, 'Invalid project entry: must be an object');
468
+ }
469
+ const host = String(project.repoHost || 'github').toLowerCase();
470
+ if (host !== 'ado') return; // GitHub / generic projects have no required GUID
471
+
472
+ const missing = [];
473
+ if (!String(project.repositoryId || '').trim()) missing.push('repositoryId');
474
+ if (!String(project.adoOrg || '').trim()) missing.push('adoOrg');
475
+ if (!String(project.adoProject || '').trim()) missing.push('adoProject');
476
+ if (!String(project.repoName || '').trim()) missing.push('repoName');
477
+ if (missing.length === 0) return;
478
+
479
+ const label = project.name || project.localPath || '<unknown>';
480
+ const hint = missing.includes('repositoryId')
481
+ ? ' Resolve via Azure CLI (`az login` + `az repos show`) or by linking from a host where the ADO REST API is reachable.'
482
+ : '';
483
+ throw _httpError(400,
484
+ `ADO project "${label}" missing required field(s): ${missing.join(', ')}.${hint}`,
485
+ { missing, code: 'PROJECT_LINK_INVALID_ADO' });
486
+ }
487
+
488
+ async function addProject(target, options = {}) {
489
+ const inputPath = typeof target === 'string' ? target : (target && target.path);
490
+ if (!inputPath || typeof inputPath !== 'string') {
491
+ throw _httpError(400, 'addProject: target path required');
492
+ }
493
+
494
+ // 1. Validate path (must exist + be a git repo OR caller opts in via
495
+ // allowNonRepo + confirmToken — same contract as the dashboard handler).
496
+ const resolved = shared.validateProjectPath(inputPath, {
497
+ allowNonRepo: options.allowNonRepo === true,
498
+ confirmToken: options.confirmToken,
499
+ isValidToken: options.isValidToken,
500
+ });
501
+
502
+ // 2. Pre-flight duplicate check OUTSIDE the lock so the common case
503
+ // (already-linked) returns a clean 400 without churning the config file.
504
+ // Resolve config path through `shared.MINIONS_DIR` (fresh on each call)
505
+ // so tests that swap MINIONS_DIR via createTestMinionsDir() see the
506
+ // right path even when projects.js itself isn't isolated.
507
+ const configPath = path.join(shared.MINIONS_DIR, 'config.json');
508
+ const existingConfig = shared.safeJson(configPath) || { projects: [] };
509
+ const existingProjects = Array.isArray(existingConfig.projects) ? existingConfig.projects : [];
510
+ if (existingProjects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
511
+ throw _httpError(400, `Project already linked at ${resolved}`);
512
+ }
513
+
514
+ // 3. Discover metadata. Async path includes the official ADO REST fallback
515
+ // for repositoryId when `az` CLI isn't available — see
516
+ // project-discovery.js#discoverProjectMetadataAsync.
517
+ const detected = await projectDiscovery.discoverProjectMetadataAsync(resolved, options);
518
+ if (!detected.name) detected.name = path.basename(resolved);
519
+
520
+ // 4. Validate name (rejects shell metacharacters, path separators, …).
521
+ const rawName = options.name || detected.name;
522
+ const name = shared.validateProjectName(rawName);
523
+
524
+ // 5. Build entry.
525
+ const project = projectDiscovery.buildProjectEntry({
526
+ name,
527
+ description: options.description !== undefined ? options.description : (detected.description || ''),
528
+ localPath: resolved,
529
+ repoHost: detected.repoHost || 'github',
530
+ repositoryId: detected.repositoryId || '',
531
+ org: detected.org || '',
532
+ project: detected.project || '',
533
+ repoName: detected.repoName || name,
534
+ mainBranch: detected.mainBranch || 'main',
535
+ prUrlBase: detected.prUrlBase,
536
+ worktreeMode: options.worktreeMode,
537
+ observeAuthors: Array.isArray(options.observeAuthors) ? options.observeAuthors : undefined,
538
+ });
539
+
540
+ // 6. Fail-closed gate. AFTER buildProjectEntry — buildProjectEntry never
541
+ // re-resolves metadata, so this check sees the same shape the engine
542
+ // will. Throws statusCode=400 with `missing[]` populated.
543
+ assertProjectLinkOk(project);
544
+
545
+ // 7. Ensure project state files (projects/<name>/...).
546
+ shared.ensureProjectStateFiles(project);
547
+
548
+ // 8. Persist under config lock. Re-check inside the lock so a concurrent
549
+ // add can't insert a duplicate between our pre-check and the write.
550
+ let duplicate = false;
551
+ shared.mutateJsonFileLocked(configPath, (config) => {
552
+ if (!config || typeof config !== 'object' || Array.isArray(config)) config = { projects: [], agents: {}, engine: {} };
553
+ if (!Array.isArray(config.projects)) config.projects = [];
554
+ if (config.projects.some(p => shared.sameResolvedPath(p.localPath, resolved))) {
555
+ duplicate = true;
556
+ return config;
557
+ }
558
+ config.projects.push(project);
559
+ return config;
560
+ }, { defaultValue: { projects: [], agents: {}, engine: {} }, skipWriteIfUnchanged: true });
561
+
562
+ if (duplicate) throw _httpError(400, `Project already linked at ${resolved}`);
563
+
564
+ return { ok: true, project, detected, path: resolved };
565
+ }
566
+
567
+ module.exports.assertProjectLinkOk = assertProjectLinkOk;
568
+ module.exports.addProject = addProject;
569
+
570
+ module.exports.removeProject = removeProject;
package/minions.js CHANGED
@@ -129,12 +129,39 @@ async function addProject(targetDir) {
129
129
  const repositoryId = await ask('Repository ID (GUID, optional)', detected.repositoryId || '');
130
130
  const mainBranch = await ask('Main branch', detected.mainBranch || 'main');
131
131
 
132
+ // W-mq8li79a000889fa: prompt for observed authors on ADO projects so the
133
+ // ado-author-prs watch plugin has a configured author list to track.
134
+ // Empty string -> no observeAuthors persisted (back-compat).
135
+ let observeAuthors;
136
+ if (String(repoHost || '').toLowerCase() === 'ado') {
137
+ const raw = await ask('Observe authors (comma-separated emails, optional)', '');
138
+ const list = String(raw || '').split(',').map(s => s.trim()).filter(Boolean);
139
+ if (list.length > 0) observeAuthors = list;
140
+ }
141
+
132
142
  rl.close();
133
143
 
134
144
  const projectEntry = buildProjectEntry({
135
145
  name, description, localPath: target, repoHost, repositoryId, org, project, repoName, mainBranch,
136
146
  prUrlBase: detected.prUrlBase,
147
+ observeAuthors,
137
148
  });
149
+
150
+ // W-mq8li79a000889fa: fail-closed gate. ADO projects must have repositoryId
151
+ // (the engine's ADO REST endpoints take a repo GUID, not a name) plus org +
152
+ // project + repoName for the dashboard's PR routing to work. Surface the
153
+ // missing fields up front rather than letting the CLI silently persist a
154
+ // broken project.
155
+ try {
156
+ require('./engine/projects').assertProjectLinkOk(projectEntry);
157
+ } catch (e) {
158
+ console.log(`\n Error: ${e.message}`);
159
+ if (Array.isArray(e.missing) && e.missing.length > 0) {
160
+ console.log(` Missing required field(s): ${e.missing.join(', ')}`);
161
+ }
162
+ process.exit(1);
163
+ }
164
+
138
165
  shared.ensureProjectStateFiles(projectEntry);
139
166
  config.projects.push(projectEntry);
140
167
  saveConfig(config);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2164",
3
+ "version": "0.1.2166",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"