@yemi33/minions 0.1.2445 → 0.1.2447

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.
@@ -227,8 +227,42 @@ function buildNpmInstallTarballArgs(tarballPath) {
227
227
  return ['install', '-g', tarballPath];
228
228
  }
229
229
 
230
- function buildNpmUninstallArgs(packageName) {
231
- return ['uninstall', '-g', packageName];
230
+ function buildNpmUninstallArgs(packageName, { userconfig = null } = {}) {
231
+ const args = ['uninstall', '-g', packageName];
232
+ if (userconfig) args.push('--userconfig', userconfig);
233
+ return args;
234
+ }
235
+
236
+ /**
237
+ * Reinstall a package from whatever registry the caller's own npm config points
238
+ * at. This is the rollback path: it deliberately carries no `--registry`,
239
+ * because the package being restored is the one that was already there and the
240
+ * internal feed is exactly what may have just failed.
241
+ */
242
+ function buildNpmRestoreArgs(packageName, { userconfig = null } = {}) {
243
+ const args = ['install', '-g', packageName];
244
+ if (userconfig) args.push('--userconfig', userconfig);
245
+ return args;
246
+ }
247
+
248
+ const NPM_GLOBAL_PATH_KINDS = new Set(['root', 'prefix']);
249
+
250
+ /**
251
+ * `npm root -g` / `npm prefix -g` under an explicit config file.
252
+ *
253
+ * Both answers are prefix-sensitive, and a custom prefix (the reported customer
254
+ * install uses `C:\.tools\.npm-global`) usually comes from an npmrc rather than
255
+ * from a flag. Resolving them under the SAME `--userconfig` the install uses is
256
+ * what stops the probe and the install from disagreeing about which prefix owns
257
+ * the `minions` shim we are about to inspect, clear, and recreate.
258
+ */
259
+ function buildNpmGlobalPathArgs(kind, { userconfig = null } = {}) {
260
+ if (!NPM_GLOBAL_PATH_KINDS.has(kind)) {
261
+ throw new Error(`npm global path kind must be "root" or "prefix", got: ${kind}`);
262
+ }
263
+ const args = [kind, '-g'];
264
+ if (userconfig) args.push('--userconfig', userconfig);
265
+ return args;
232
266
  }
233
267
 
234
268
  /** Resolve the on-disk root of a globally installed package from `npm root -g`. */
@@ -292,6 +326,24 @@ function resolveBinShimPaths(npmPrefixG, platform = process.platform) {
292
326
  return [path.join(prefix, 'bin', 'minions')];
293
327
  }
294
328
 
329
+ /**
330
+ * Does a `minions` shim occupy this path? Deliberately `lstat`, never
331
+ * `existsSync`: `existsSync` stats THROUGH a symlink and answers `false` for a
332
+ * dangling one — which is precisely the orphan we are hunting, npm's
333
+ * `<prefix>/bin/minions` link outliving the package directory it pointed into.
334
+ * That link still makes `npm install -g` abort with EEXIST, so "the target is
335
+ * gone" must never read as "the path is free".
336
+ */
337
+ function shimPathPresent(shimPath, deps = {}) {
338
+ const lstat = deps.lstatSync || (p => fs.lstatSync(p));
339
+ try {
340
+ lstat(shimPath);
341
+ return true;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+
295
347
  /**
296
348
  * Does this shim dispatch into `packageName`? The scoped name is discriminating,
297
349
  * so a shim left behind by `@yemi33/minions` cannot pass as `@opg-microsoft/minions`.
@@ -325,9 +377,13 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
325
377
  const candidates = resolveBinShimPaths(npmPrefixG, platform);
326
378
  const shims = [];
327
379
  for (const shimPath of candidates) {
328
- if (!exists(shimPath)) continue;
380
+ if (!shimPathPresent(shimPath, deps)) continue;
329
381
  shims.push({
330
382
  path: shimPath,
383
+ // `exists` follows the link on purpose here: the question is whether the
384
+ // command a user types can actually run, so a shim whose target is gone
385
+ // is broken no matter which package its link names.
386
+ broken: !exists(shimPath),
331
387
  matches: shimTargetsPackage({ shimPath, packageName, deps }),
332
388
  matchesPublic: packageName === PUBLIC_PACKAGE
333
389
  ? false
@@ -337,6 +393,14 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
337
393
  if (!shims.length) {
338
394
  return { ok: false, shims, reason: `no \`minions\` shim was found under ${String(npmPrefixG).trim()}` };
339
395
  }
396
+ const broken = shims.find(shim => shim.broken);
397
+ if (broken) {
398
+ return {
399
+ ok: false,
400
+ shims,
401
+ reason: `the \`minions\` shim at ${broken.path} is a broken link — its target no longer exists`,
402
+ };
403
+ }
340
404
  const mismatched = shims.filter(shim => !shim.matches);
341
405
  if (mismatched.length) {
342
406
  const stale = mismatched.find(shim => shim.matchesPublic);
@@ -351,7 +415,167 @@ function verifyBinShims({ npmPrefixG, packageName, platform = process.platform,
351
415
  return { ok: true, shims, reason: `\`minions\` resolves to ${packageName} (${shims.map(s => path.basename(s.path)).join(', ')})` };
352
416
  }
353
417
 
354
- // ─── Portable spawning ──────────────────────────────────────────────────────
418
+ // ─── Orphaned `minions` shim repair ─────────────────────────────────────────
419
+ //
420
+ // npm refuses to overwrite a bin shim it does not currently own and aborts the
421
+ // whole global install with EEXIST. A prior `npm uninstall -g` that was
422
+ // interrupted — or any half-finished public→internal migration — can remove
423
+ // `<npm root -g>/<package>` while leaving the `minions` trio under
424
+ // `npm prefix -g`. `readGlobalPackageVersion` then reports nothing installed,
425
+ // the run looks like a fresh install, and `npm install -g` dies on the leftover.
426
+ //
427
+ // The repair is ownership-safe by construction: a shim is only ever removed
428
+ // when its body (or symlink target) names a package this installer owns AND
429
+ // that package is no longer usable on disk. Anything else — a colleague's
430
+ // script, a wrapper, an unreadable file — blocks the run with the exact path to
431
+ // resolve rather than being deleted.
432
+
433
+ /** Package names whose generated `minions` shim this installer may remove. */
434
+ const SHIM_OWNER_PACKAGES = [INTERNAL_PACKAGE, PUBLIC_PACKAGE];
435
+
436
+ /**
437
+ * Is a globally installed package still usable — package.json present AND every
438
+ * executable it declares in `bin` still on disk?
439
+ *
440
+ * "Present" is not enough. An interrupted uninstall can leave the package
441
+ * directory behind with its `bin/` already reaped, and a shim dispatching into
442
+ * that is exactly as broken as one pointing at nothing.
443
+ */
444
+ function isGlobalPackageUsable(npmRootG, packageName, deps = {}) {
445
+ const exists = deps.existsSync || (p => fs.existsSync(p));
446
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p, 'utf8'));
447
+ if (!String(npmRootG == null ? '' : npmRootG).trim()) return false;
448
+ let root;
449
+ try { root = resolveGlobalPackageRoot(npmRootG, packageName); } catch { return false; }
450
+ let manifest;
451
+ try { manifest = JSON.parse(readFile(path.join(root, 'package.json'))); } catch { return false; }
452
+ const bin = manifest && manifest.bin;
453
+ const targets = typeof bin === 'string'
454
+ ? [bin]
455
+ : (bin && typeof bin === 'object' ? Object.values(bin).filter(v => typeof v === 'string') : []);
456
+ if (!targets.length) return exists(root);
457
+ return targets.every(rel => exists(path.join(root, ...String(rel).split('/'))));
458
+ }
459
+
460
+ /**
461
+ * Inspect every platform-specific `minions` shim under the exact prefix the
462
+ * install will use and decide what — if anything — has to be cleared first.
463
+ *
464
+ * Returns `{ needsRepair, blocked, remove, shims, reason }`:
465
+ * - `blocked` an existing `minions` file could not be attributed to a
466
+ * package we own. Nothing is scheduled for deletion and the
467
+ * caller must refuse; `reason` names the exact path.
468
+ * - `needsRepair` at least one shim is orphaned or stale. `remove` is the
469
+ * COMPLETE npm-generated set that exists (the Windows
470
+ * `.cmd`/`.ps1`/sh trio is written and rewritten as a unit, so
471
+ * leaving a sibling behind just moves the EEXIST).
472
+ *
473
+ * An unresolvable prefix or root is a skip, never a refusal and never a
474
+ * deletion: without both we cannot tell a shim from an unrelated file.
475
+ */
476
+ function assessOrphanShims({
477
+ npmPrefixG,
478
+ npmRootG,
479
+ packageNames = SHIM_OWNER_PACKAGES,
480
+ platform = process.platform,
481
+ deps = {},
482
+ } = {}) {
483
+ const prefix = String(npmPrefixG == null ? '' : npmPrefixG).trim();
484
+ const root = String(npmRootG == null ? '' : npmRootG).trim();
485
+ const owners = (packageNames && packageNames.length ? packageNames : SHIM_OWNER_PACKAGES);
486
+ const skip = reason => ({ needsRepair: false, blocked: false, remove: [], shims: [], reason });
487
+
488
+ if (!prefix || !root) {
489
+ return skip('npm did not report both a global prefix and a global root; shim repair skipped');
490
+ }
491
+ let candidates;
492
+ try { candidates = resolveBinShimPaths(prefix, platform); } catch { return skip('the global npm prefix could not be resolved; shim repair skipped'); }
493
+
494
+ const shims = [];
495
+ for (const shimPath of candidates) {
496
+ if (!shimPathPresent(shimPath, deps)) continue;
497
+ const owner = owners.find(name => shimTargetsPackage({ shimPath, packageName: name, deps })) || null;
498
+ shims.push({ path: shimPath, owner, usable: owner ? isGlobalPackageUsable(root, owner, deps) : false });
499
+ }
500
+ if (!shims.length) {
501
+ return { needsRepair: false, blocked: false, remove: [], shims, reason: `no \`minions\` shim exists under ${prefix}` };
502
+ }
503
+
504
+ // Ownership first, and wholesale: a prefix holding one unattributable
505
+ // `minions` file is not a prefix we are willing to partially clear.
506
+ const unprovable = shims.filter(shim => !shim.owner);
507
+ if (unprovable.length) {
508
+ return {
509
+ needsRepair: false,
510
+ blocked: true,
511
+ remove: [],
512
+ shims,
513
+ reason: `${unprovable.map(s => s.path).join(', ')} is named \`minions\` but was not generated by npm for `
514
+ + `${owners.join(' or ')}; refusing to delete a file this installer does not own. `
515
+ + 'Move or rename it (or remove it yourself if it is a leftover) and re-run.',
516
+ };
517
+ }
518
+
519
+ const orphans = shims.filter(shim => !shim.usable);
520
+ if (!orphans.length) {
521
+ return {
522
+ needsRepair: false,
523
+ blocked: false,
524
+ remove: [],
525
+ shims,
526
+ reason: `every \`minions\` shim under ${prefix} is backed by an installed package`,
527
+ };
528
+ }
529
+ const staleOwners = [...new Set(orphans.map(shim => shim.owner))];
530
+ return {
531
+ needsRepair: true,
532
+ blocked: false,
533
+ remove: shims.map(shim => shim.path),
534
+ shims,
535
+ reason: `${orphans.length} \`minions\` shim(s) under ${prefix} still dispatch into ${staleOwners.join(', ')}, `
536
+ + 'which is no longer installed; clearing the npm-generated set so `npm install -g` can recreate it',
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Delete the shims `assessOrphanShims` scheduled. Idempotent — a path that is
542
+ * already gone is not a failure, so re-running a partially completed repair
543
+ * converges instead of erroring.
544
+ *
545
+ * `unlink` first, not `rm`: `fs.rmSync(p, { force: true })` decides "already
546
+ * absent" with a symlink-following existence check, so it returns success
547
+ * WITHOUT clearing a dangling link — the one case this repair exists for.
548
+ * `rm` stays as the fallback for whatever unlink refuses. Every removal is then
549
+ * re-probed, because a delete that silently no-ops must be reported as a
550
+ * failure the caller can act on rather than as a cleared path npm will still
551
+ * abort on.
552
+ */
553
+ function removeOrphanShims(paths, deps = {}) {
554
+ const unlink = deps.unlinkSync || (p => fs.unlinkSync(p));
555
+ const remove = deps.rmSync || ((p, options) => fs.rmSync(p, options));
556
+ const removed = [];
557
+ const failed = [];
558
+ for (const target of paths || []) {
559
+ if (!shimPathPresent(target, deps)) continue;
560
+ try {
561
+ try {
562
+ unlink(target);
563
+ } catch (e) {
564
+ if (e && e.code === 'ENOENT') continue;
565
+ remove(target, { force: true });
566
+ }
567
+ if (shimPathPresent(target, deps)) {
568
+ throw new Error('the delete reported success but the path still exists');
569
+ }
570
+ removed.push(target);
571
+ } catch (e) {
572
+ failed.push({ path: target, error: e && e.message ? e.message : String(e) });
573
+ }
574
+ }
575
+ return { removed, failed };
576
+ }
577
+
578
+
355
579
  //
356
580
  // On Windows both `npm` and `az` are `.cmd` shims. Node >= 18.20 refuses to
357
581
  // spawn `.cmd`/`.bat` without a shell (CVE-2024-27980), and `shell: true` does
@@ -507,6 +731,96 @@ function diffPreservedPaths(before, after) {
507
731
  return before.filter(rel => !afterSet.has(rel));
508
732
  }
509
733
 
734
+ /**
735
+ * Runtime paths that the PACKAGE ships and `minions init --force` therefore
736
+ * rewrites with package defaults (`bin/minions.js#copyDir` force-overwrites
737
+ * every shipped file except `config.json`), yet whose installed contents are
738
+ * operator- or agent-authored:
739
+ *
740
+ * - `routing.md` the operator's work-type → agent routing table
741
+ * - `knowledge/agents/` per-agent memory written by engine/consolidation.js
742
+ *
743
+ * A package-channel cutover must not reseed either. Their bytes are captured
744
+ * before `sync-init` and put back after it, so the sync can still add newly
745
+ * required files and run schema migrations while existing content survives.
746
+ */
747
+ const INIT_RESEEDABLE_PATHS = ['routing.md', 'knowledge/agents'];
748
+
749
+ /**
750
+ * Read the current bytes of every reseedable file under a runtime root.
751
+ * Returns `[{ rel, contents }]` — a list, not a tree, so restoring is a plain
752
+ * write per entry with no directory reconciliation.
753
+ */
754
+ function snapshotReseedableFiles(runtimeRoot, deps = {}) {
755
+ const exists = deps.existsSync || (p => fs.existsSync(p));
756
+ const stat = deps.statSync || (p => fs.statSync(p));
757
+ const readdir = deps.readdirSync || (p => fs.readdirSync(p));
758
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p));
759
+ const snapshot = [];
760
+ const visit = (rel) => {
761
+ const full = path.join(runtimeRoot, ...rel.split('/'));
762
+ if (!exists(full)) return;
763
+ let info;
764
+ try { info = stat(full); } catch { return; }
765
+ if (info.isDirectory()) {
766
+ let entries;
767
+ try { entries = readdir(full); } catch { return; }
768
+ for (const entry of entries) visit(`${rel}/${String(entry && entry.name !== undefined ? entry.name : entry)}`);
769
+ return;
770
+ }
771
+ try { snapshot.push({ rel, contents: readFile(full) }); } catch { /* unreadable — nothing to restore */ }
772
+ };
773
+ for (const rel of INIT_RESEEDABLE_PATHS) visit(rel);
774
+ return snapshot;
775
+ }
776
+
777
+ /**
778
+ * Put back every snapshotted file whose bytes the sync replaced or removed.
779
+ * Returns the relative paths actually restored, so the operator sees exactly
780
+ * what the package tried to reseed.
781
+ */
782
+ function restoreReseededFiles(runtimeRoot, snapshot, deps = {}) {
783
+ const readFile = deps.readFileSync || (p => fs.readFileSync(p));
784
+ const writeFile = deps.writeFileSync || ((p, data) => fs.writeFileSync(p, data));
785
+ const mkdir = deps.mkdirSync || ((p, options) => fs.mkdirSync(p, options));
786
+ const restored = [];
787
+ for (const entry of snapshot || []) {
788
+ const full = path.join(runtimeRoot, ...entry.rel.split('/'));
789
+ let current = null;
790
+ try { current = readFile(full); } catch { current = null; }
791
+ if (current && Buffer.isBuffer(current) && Buffer.from(entry.contents).equals(current)) continue;
792
+ try {
793
+ mkdir(path.dirname(full), { recursive: true });
794
+ writeFile(full, entry.contents);
795
+ restored.push(entry.rel);
796
+ } catch { /* best-effort; the pre-migration backup is still the fallback */ }
797
+ }
798
+ return restored;
799
+ }
800
+
801
+ /**
802
+ * Decide whether a failed cutover must put the previously installed package
803
+ * back so the machine is not left with no `minions` command at all.
804
+ *
805
+ * Deliberately PACKAGE-scoped. User state is never rolled backward: the
806
+ * pre-migration backup exists for a human to reach for, and state created
807
+ * before or during the cutover (work items, dispatches, PR records) must
808
+ * survive a failed package swap untouched.
809
+ */
810
+ function assessRollback({ publicUninstalled = false, internalUsable = false } = {}) {
811
+ if (!publicUninstalled) {
812
+ return { needed: false, reason: 'no previously installed package was removed' };
813
+ }
814
+ if (internalUsable) {
815
+ return { needed: false, reason: `${INTERNAL_PACKAGE} is installed and owns the \`minions\` command` };
816
+ }
817
+ return {
818
+ needed: true,
819
+ packageName: PUBLIC_PACKAGE,
820
+ reason: `${PUBLIC_PACKAGE} was removed and the cutover did not leave a usable \`minions\` command`,
821
+ };
822
+ }
823
+
510
824
  /**
511
825
  * Count live agent processes by walking the dispatch PID files the engine
512
826
  * writes under `<runtime root>/engine/tmp/` — both the per-dispatch directory
@@ -579,6 +893,7 @@ const STEP_ORDER = [
579
893
  'download-artifact',
580
894
  'backup-state',
581
895
  'uninstall-public',
896
+ 'repair-shims',
582
897
  'install-internal',
583
898
  'verify-install',
584
899
  'verify-shim',
@@ -601,6 +916,11 @@ const STEP_ORDER = [
601
916
  * restore point.
602
917
  * - `uninstall-public` precedes `install-internal` so the two packages never
603
918
  * race for the same `minions` bin shim.
919
+ * - `repair-shims` sits between them: npm removes the shims it still owns
920
+ * during `uninstall-public`, so whatever survives to this point is an
921
+ * orphan, and clearing it is what keeps `install-internal` from aborting
922
+ * with EEXIST. It runs after `backup-state`, so nothing is ever deleted
923
+ * before a restore point exists.
604
924
  * - `verify-shim` precedes `sync-init`, so the runtime is only synchronized
605
925
  * once `minions` actually resolves to the internal package.
606
926
  * - `cleanup` is always last and always present — it runs in a `finally`.
@@ -634,6 +954,9 @@ function planSteps(state) {
634
954
  if (alreadyAtTarget) {
635
955
  skip.add('install-internal');
636
956
  skip.add('download-artifact');
957
+ // No install means no shim for the install to recreate, so there is nothing
958
+ // to make room for — and nothing this run is entitled to delete.
959
+ skip.add('repair-shims');
637
960
  }
638
961
  if (!restart) skip.add('restart');
639
962
 
@@ -729,6 +1052,17 @@ function readGlobalPackageVersion(npmRootG, packageName) {
729
1052
  } catch { return null; }
730
1053
  }
731
1054
 
1055
+ /**
1056
+ * Ask npm for its global root or prefix under an explicit config. Returns null
1057
+ * rather than throwing: a prefix npm cannot report downgrades the shim work to
1058
+ * a skipped, non-destructive warning instead of failing the migration.
1059
+ */
1060
+ function resolveNpmGlobalPath(run, kind, config) {
1061
+ try {
1062
+ return run('npm', buildNpmGlobalPathArgs(kind, config), { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim() || null;
1063
+ } catch { return null; }
1064
+ }
1065
+
732
1066
  // ─── Main ───────────────────────────────────────────────────────────────────
733
1067
 
734
1068
  function main(argv) {
@@ -749,7 +1083,13 @@ function main(argv) {
749
1083
  const log = (...args) => console.log(...args);
750
1084
  const run = makeRunner(secrets, { log });
751
1085
  const scope = packageScope(opts.packageName);
1086
+ // The runtime root is resolved ONCE, before any package is touched, and is
1087
+ // pinned onto every child process below (backup, init, restart) rather than
1088
+ // left to whatever MINIONS_HOME each child happens to inherit. A cutover that
1089
+ // backed up one root and synchronized another would look successful and lose
1090
+ // the operator's state.
752
1091
  const runtimeRoot = resolveRuntimeRoot();
1092
+ const runtimeEnv = { ...process.env, MINIONS_HOME: runtimeRoot };
753
1093
  const hasExistingRuntime = fs.existsSync(path.join(runtimeRoot, 'engine.js'));
754
1094
 
755
1095
  log(`\n Internal Minions install`);
@@ -757,25 +1097,16 @@ function main(argv) {
757
1097
  log(` registry: ${opts.registry}`);
758
1098
  log(` runtime root: ${runtimeRoot}${hasExistingRuntime ? '' : ' (fresh install)'}`);
759
1099
 
1100
+ // npm-usability probe only. The authoritative global root and prefix are
1101
+ // re-resolved below under the same `--userconfig` the install uses, because a
1102
+ // custom prefix (e.g. `C:\.tools\.npm-global`) normally comes from an npmrc.
760
1103
  let npmRootG = null;
761
1104
  try {
762
- npmRootG = run('npm', ['root', '-g'], { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim();
1105
+ npmRootG = run('npm', buildNpmGlobalPathArgs('root'), { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim();
763
1106
  } catch (e) {
764
1107
  console.error(`\n ERROR: npm is required but not usable: ${e.message}\n`);
765
1108
  return EXIT.FAILED;
766
1109
  }
767
- const installedInternalVersion = readGlobalPackageVersion(npmRootG, opts.packageName);
768
- const installedPublic = !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE);
769
-
770
- // The global bin prefix is where the `minions` shim lives. Resolved up front so
771
- // a prefix npm itself cannot report is a warning, not a late failure after the
772
- // package is already installed.
773
- let npmPrefixG = null;
774
- try {
775
- npmPrefixG = run('npm', ['prefix', '-g'], { timeout: NPM_VIEW_TIMEOUT_MS, capture: true }).stdout.trim() || null;
776
- } catch {
777
- npmPrefixG = null;
778
- }
779
1110
 
780
1111
  // Agent-activity gate — evaluated before anything is acquired or mutated.
781
1112
  const activity = assessAgentActivity({
@@ -828,6 +1159,20 @@ function main(argv) {
828
1159
  tempDir = temp.dir;
829
1160
  log(` npm auth staged in a temporary config (removed when this run ends).`);
830
1161
 
1162
+ // From here on every prefix-sensitive npm command — root, prefix, uninstall,
1163
+ // install, and the shim cleanup that reads the prefix — resolves through the
1164
+ // SAME config file. A custom global prefix (the reported install uses
1165
+ // `C:\.tools\.npm-global`) normally comes from an npmrc, so without this the
1166
+ // probe could read one config while the install honored another, and the
1167
+ // shim we inspected would not be the shim npm collides with.
1168
+ const npmConfig = { userconfig: temp.file };
1169
+ const resolvedRoot = resolveNpmGlobalPath(run, 'root', npmConfig);
1170
+ if (resolvedRoot) npmRootG = resolvedRoot;
1171
+ const npmPrefixG = resolveNpmGlobalPath(run, 'prefix', npmConfig);
1172
+
1173
+ const installedInternalVersion = readGlobalPackageVersion(npmRootG, opts.packageName);
1174
+ const installedPublic = !!readGlobalPackageVersion(npmRootG, PUBLIC_PACKAGE);
1175
+
831
1176
  // 2. validate-feed ─────────────────────────────────────────────────────
832
1177
  const viewArgs = buildNpmViewArgs({
833
1178
  packageName: opts.packageName,
@@ -882,6 +1227,22 @@ function main(argv) {
882
1227
  shimOwned,
883
1228
  });
884
1229
  log(`\n Plan: ${steps.join(' → ')}`);
1230
+
1231
+ // Orphaned-shim gate. Evaluated BEFORE anything is downloaded, backed up, or
1232
+ // uninstalled so an unattributable `minions` file refuses the run up front
1233
+ // instead of surfacing halfway through a cutover — and so the reported
1234
+ // EEXIST is caught here rather than by npm.
1235
+ let orphanShims = { needsRepair: false, blocked: false, remove: [], reason: 'shim repair is not part of this plan' };
1236
+ if (steps.includes('repair-shims')) {
1237
+ orphanShims = assessOrphanShims({ npmPrefixG, npmRootG, packageNames: SHIM_OWNER_PACKAGES });
1238
+ if (orphanShims.blocked) {
1239
+ console.error(`\n REFUSED: ${orphanShims.reason}`);
1240
+ console.error(' Nothing was downloaded, uninstalled, or modified.\n');
1241
+ return EXIT.REFUSED;
1242
+ }
1243
+ if (orphanShims.needsRepair) log(` Orphaned shims detected: ${orphanShims.remove.join(', ')}`);
1244
+ }
1245
+
885
1246
  if (installedInternalVersion === targetVersion && !steps.includes('install-internal')) {
886
1247
  log(` ${opts.packageName}@${targetVersion} is already installed — install step skipped (idempotent).`);
887
1248
  } else if (installedInternalVersion === targetVersion && !shimOwned) {
@@ -894,7 +1255,12 @@ function main(argv) {
894
1255
  log(` npm ${redact(buildNpmPackArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file, destination: '<temp>' }).join(' '), secrets)}`);
895
1256
  }
896
1257
  if (steps.includes('backup-state')) log(` minions state backup <backup-dir>/state.db`);
897
- if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE).join(' ')}`);
1258
+ if (steps.includes('uninstall-public')) log(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig).join(' ')}`);
1259
+ if (steps.includes('repair-shims')) {
1260
+ log(orphanShims.needsRepair
1261
+ ? ` (repair) remove orphaned shims: ${orphanShims.remove.join(', ')}`
1262
+ : ` (repair) no orphaned \`minions\` shim to clear`);
1263
+ }
898
1264
  if (steps.includes('install-internal')) {
899
1265
  log(` npm ${redact(buildNpmInstallArgs({ packageName: opts.packageName, version: targetVersion, registry: opts.registry, userconfig: temp.file }).join(' '), secrets)}`);
900
1266
  log(` (on failure) npm ${buildNpmInstallTarballArgs('<backup-dir>/<package>.tgz').join(' ')}`);
@@ -970,7 +1336,7 @@ function main(argv) {
970
1336
  run(command, args, {
971
1337
  timeout: CLI_INIT_TIMEOUT_MS,
972
1338
  capture: true,
973
- env: { ...process.env, MINIONS_HOME: runtimeRoot },
1339
+ env: runtimeEnv,
974
1340
  });
975
1341
  if (!fs.existsSync(backupStatePath)) throw new Error('the backup command reported success but wrote no file');
976
1342
  log(` SQLite state checkpointed and backed up (via ${backup.kind === 'runtime-module' ? 'the runtime\'s own engine/state-operations.js' : backup.cliPath}).`);
@@ -1009,6 +1375,32 @@ function main(argv) {
1009
1375
  }
1010
1376
 
1011
1377
  // 5. uninstall-public ──────────────────────────────────────────────────
1378
+ let publicUninstalled = false;
1379
+
1380
+ /**
1381
+ * Put the previous package back when a failed cutover would otherwise leave
1382
+ * the machine with no `minions` command. Package-scoped ONLY: the state
1383
+ * backup is never restored automatically, so state written before or during
1384
+ * the cutover is neither rolled backward nor deleted.
1385
+ */
1386
+ const rollbackPackageIfNeeded = () => {
1387
+ const decision = assessRollback({
1388
+ publicUninstalled,
1389
+ internalUsable: isGlobalPackageUsable(npmRootG, opts.packageName)
1390
+ && (!npmPrefixG || verifyBinShims({ npmPrefixG, packageName: opts.packageName }).ok),
1391
+ });
1392
+ if (!decision.needed) return;
1393
+ console.error(`\n Rolling back the package cutover: ${decision.reason}.`);
1394
+ console.error(` Restoring ${decision.packageName} so \`minions\` works again. Your runtime state is untouched.`);
1395
+ try {
1396
+ run('npm', buildNpmRestoreArgs(decision.packageName, npmConfig), { timeout: NPM_INSTALL_TIMEOUT_MS });
1397
+ console.error(` Restored ${decision.packageName}.`);
1398
+ } catch (e) {
1399
+ console.error(` Could not restore ${decision.packageName} automatically (${e.message}).`);
1400
+ console.error(` Run this yourself: npm ${buildNpmRestoreArgs(decision.packageName).join(' ')}`);
1401
+ }
1402
+ };
1403
+
1012
1404
  if (steps.includes('uninstall-public')) {
1013
1405
  const readiness = assessCutoverReadiness({
1014
1406
  artifactReady: !!artifactPath,
@@ -1022,10 +1414,43 @@ function main(argv) {
1022
1414
  return EXIT.FAILED;
1023
1415
  }
1024
1416
  log(`\n Removing the public ${PUBLIC_PACKAGE} global install (${readiness.reason})...`);
1025
- run('npm', buildNpmUninstallArgs(PUBLIC_PACKAGE), { timeout: NPM_INSTALL_TIMEOUT_MS, allowFailure: true });
1417
+ run('npm', buildNpmUninstallArgs(PUBLIC_PACKAGE, npmConfig), { timeout: NPM_INSTALL_TIMEOUT_MS, allowFailure: true });
1418
+ publicUninstalled = true;
1026
1419
  }
1027
1420
 
1028
- // 6. install-internal ──────────────────────────────────────────────────
1421
+ // 6. repair-shims ──────────────────────────────────────────────────────
1422
+ // npm has now removed whatever shims it still owned. Anything left under the
1423
+ // prefix that names a Minions package with no usable install behind it is an
1424
+ // ORPHAN — npm will not overwrite it and aborts the global install with
1425
+ // EEXIST. Re-assessed here (not reused from the gate above) because the
1426
+ // uninstall that just ran is exactly what changes the answer.
1427
+ if (steps.includes('repair-shims')) {
1428
+ const repair = assessOrphanShims({ npmPrefixG, npmRootG, packageNames: SHIM_OWNER_PACKAGES });
1429
+ if (repair.blocked) {
1430
+ console.error(`\n REFUSED: ${repair.reason}`);
1431
+ if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
1432
+ rollbackPackageIfNeeded();
1433
+ console.error('');
1434
+ return EXIT.REFUSED;
1435
+ }
1436
+ if (repair.needsRepair) {
1437
+ log(`\n Clearing orphaned \`minions\` shims — ${repair.reason}.`);
1438
+ const removal = removeOrphanShims(repair.remove);
1439
+ for (const cleared of removal.removed) log(` removed ${cleared}`);
1440
+ if (removal.failed.length) {
1441
+ console.error(`\n ERROR: could not remove ${removal.failed.map(f => `${f.path} (${f.error})`).join('; ')}.`);
1442
+ console.error(' `npm install -g` would abort with EEXIST on it. Remove the file(s) manually and re-run.');
1443
+ if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
1444
+ rollbackPackageIfNeeded();
1445
+ console.error('');
1446
+ return EXIT.FAILED;
1447
+ }
1448
+ } else {
1449
+ log(`\n Shim check: ${repair.reason}.`);
1450
+ }
1451
+ }
1452
+
1453
+ // 7. install-internal ──────────────────────────────────────────────────
1029
1454
  if (steps.includes('install-internal')) {
1030
1455
  log(`\n Installing ${opts.packageName}@${targetVersion} ...`);
1031
1456
  let installed = false;
@@ -1061,25 +1486,24 @@ function main(argv) {
1061
1486
  console.error(` Retry the offline install yourself with:`);
1062
1487
  console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
1063
1488
  }
1064
- if (steps.includes('uninstall-public')) {
1065
- console.error(` Or fall back to the public package with:`);
1066
- console.error(` npm install -g ${PUBLIC_PACKAGE}`);
1067
- }
1489
+ rollbackPackageIfNeeded();
1068
1490
  console.error('');
1069
1491
  return EXIT.FAILED;
1070
1492
  }
1071
1493
  }
1072
1494
 
1073
- // 7. verify-install ────────────────────────────────────────────────────
1495
+ // 8. verify-install ────────────────────────────────────────────────────
1074
1496
  const installedNow = readGlobalPackageVersion(npmRootG, opts.packageName);
1075
1497
  if (installedNow !== targetVersion) {
1076
1498
  console.error(`\n ERROR: expected ${opts.packageName}@${targetVersion} on disk, found ${installedNow || 'nothing'}.`);
1077
- console.error(' npm may have resolved a stale metadata cache. Try `npm cache clean --force` and re-run.\n');
1499
+ console.error(' npm may have resolved a stale metadata cache. Try `npm cache clean --force` and re-run.');
1500
+ rollbackPackageIfNeeded();
1501
+ console.error('');
1078
1502
  return EXIT.FAILED;
1079
1503
  }
1080
1504
  log(` Verified ${opts.packageName}@${installedNow} on disk.`);
1081
1505
 
1082
- // 8. verify-shim ───────────────────────────────────────────────────────
1506
+ // 9. verify-shim ───────────────────────────────────────────────────────
1083
1507
  // Owning the package directory is not the same as owning the `minions`
1084
1508
  // command: a half-finished cutover can leave the shim wired to the public
1085
1509
  // package while the internal one sits installed and unused.
@@ -1090,9 +1514,10 @@ function main(argv) {
1090
1514
  console.error(`\n ERROR: ${shimCheck.reason}.`);
1091
1515
  console.error(` ${opts.packageName}@${installedNow} is installed, but \`minions\` does not run it.`);
1092
1516
  console.error(` Repair the shim with:`);
1093
- console.error(` npm uninstall -g ${PUBLIC_PACKAGE}`);
1517
+ console.error(` npm ${buildNpmUninstallArgs(PUBLIC_PACKAGE).join(' ')}`);
1094
1518
  if (artifactPath) console.error(` npm ${buildNpmInstallTarballArgs(artifactPath).join(' ')}`);
1095
1519
  if (backupDir) console.error(` Your state backup is intact at ${backupDir}.`);
1520
+ rollbackPackageIfNeeded();
1096
1521
  console.error('');
1097
1522
  return EXIT.FAILED;
1098
1523
  }
@@ -1103,18 +1528,32 @@ function main(argv) {
1103
1528
 
1104
1529
  const installedCliPath = path.join(resolveGlobalPackageRoot(npmRootG, opts.packageName), 'bin', 'minions.js');
1105
1530
 
1106
- // 9. sync-init ─────────────────────────────────────────────────────────
1531
+ // 10. sync-init ────────────────────────────────────────────────────────
1532
+ // `minions init --force` overwrites every file the package ships, which
1533
+ // includes `routing.md` and `knowledge/agents/*.md`. Those are operator- and
1534
+ // agent-authored, so their bytes are captured here and put back below: this
1535
+ // is a package-channel cutover, and the sync may only ADD newly required
1536
+ // files and run the runtime's own idempotent startup migrations.
1537
+ const reseedable = snapshotReseedableFiles(runtimeRoot);
1107
1538
  log('\n Synchronizing the runtime root (minions init --force)...');
1108
1539
  try {
1109
- run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], { timeout: CLI_INIT_TIMEOUT_MS });
1540
+ run(process.execPath, [installedCliPath, 'init', '--force', '--skip-start'], {
1541
+ timeout: CLI_INIT_TIMEOUT_MS,
1542
+ env: runtimeEnv,
1543
+ });
1110
1544
  } catch (e) {
1111
1545
  console.error(`\n ERROR: runtime synchronization failed (${e.message}).`);
1112
1546
  console.error(` The package is installed. Finish manually with:`);
1113
1547
  console.error(` minions init --force`);
1114
- console.error(` minions restart\n`);
1548
+ console.error(` minions restart`);
1549
+ rollbackPackageIfNeeded();
1550
+ console.error('');
1115
1551
  return EXIT.FAILED;
1116
1552
  }
1117
1553
 
1554
+ const reseeded = restoreReseededFiles(runtimeRoot, reseedable);
1555
+ if (reseeded.length) log(` Restored operator-authored files the sync reseeded: ${reseeded.join(', ')}`);
1556
+
1118
1557
  const preservedAfter = snapshotPreservedPaths(runtimeRoot);
1119
1558
  const lost = diffPreservedPaths(preservedBefore, preservedAfter);
1120
1559
  if (lost.length) {
@@ -1124,11 +1563,11 @@ function main(argv) {
1124
1563
  }
1125
1564
  if (preservedBefore.length) log(` Preserved: ${preservedBefore.join(', ')}`);
1126
1565
 
1127
- // 10. restart ──────────────────────────────────────────────────────────
1566
+ // 11. restart ──────────────────────────────────────────────────────────
1128
1567
  if (steps.includes('restart')) {
1129
1568
  log('\n Restarting engine and dashboard (health-verified)...');
1130
1569
  try {
1131
- run(process.execPath, [installedCliPath, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS });
1570
+ run(process.execPath, [installedCliPath, 'restart'], { timeout: CLI_RESTART_TIMEOUT_MS, env: runtimeEnv });
1132
1571
  } catch (e) {
1133
1572
  console.error(`\n ERROR: restart failed (${e.message}).`);
1134
1573
  console.error(' The package and runtime files are up to date; run `minions restart` to finish.\n');
@@ -1146,7 +1585,7 @@ function main(argv) {
1146
1585
  console.error(`\n ERROR: ${redact(e && e.message ? e.message : String(e), secrets)}\n`);
1147
1586
  return EXIT.FAILED;
1148
1587
  } finally {
1149
- // 11. cleanup — runs on success and on every failure path, including the
1588
+ // 12. cleanup — runs on success and on every failure path, including the
1150
1589
  // early `return`s above, so the token file never outlives the process. The
1151
1590
  // temp artifact copy goes too; when there was a runtime to back up, the
1152
1591
  // recovery tarball was already parked in the backup directory.
@@ -1169,6 +1608,8 @@ module.exports = {
1169
1608
  PUBLIC_PACKAGE,
1170
1609
  EXIT,
1171
1610
  PRESERVED_PATHS,
1611
+ INIT_RESEEDABLE_PATHS,
1612
+ SHIM_OWNER_PACKAGES,
1172
1613
  STEP_ORDER,
1173
1614
  parseArgs,
1174
1615
  normalizeRegistry,
@@ -1181,11 +1622,17 @@ module.exports = {
1181
1622
  buildNpmPackArgs,
1182
1623
  buildNpmInstallTarballArgs,
1183
1624
  buildNpmUninstallArgs,
1625
+ buildNpmRestoreArgs,
1626
+ buildNpmGlobalPathArgs,
1184
1627
  resolveGlobalPackageRoot,
1185
1628
  resolvePackedTarball,
1186
1629
  resolveBinShimPaths,
1630
+ shimPathPresent,
1187
1631
  shimTargetsPackage,
1188
1632
  verifyBinShims,
1633
+ isGlobalPackageUsable,
1634
+ assessOrphanShims,
1635
+ removeOrphanShims,
1189
1636
  quoteWindowsArg,
1190
1637
  resolveSpawnTarget,
1191
1638
  redact,
@@ -1195,6 +1642,9 @@ module.exports = {
1195
1642
  resolveBackupStrategy,
1196
1643
  snapshotPreservedPaths,
1197
1644
  diffPreservedPaths,
1645
+ snapshotReseedableFiles,
1646
+ restoreReseededFiles,
1647
+ assessRollback,
1198
1648
  countActiveAgents,
1199
1649
  assessAgentActivity,
1200
1650
  assessCutoverReadiness,
package/dashboard.js CHANGED
@@ -101,6 +101,7 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
101
101
  getEngineLog, getMetrics, getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getProjectGitStatus, timeSince,
102
102
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, PRD_DIR } = queries;
103
103
  const apiContracts = require('./engine/api-contracts');
104
+ const listPaging = require('./engine/api-contracts/paging');
104
105
 
105
106
  // Dev vs binary differentiation. When two dashboards run side-by-side (npm
106
107
  // install on 7331, local checkout on 7332), the favicon and title need to
@@ -3384,7 +3385,11 @@ async function _maxInputMtimeMs(inputs) {
3384
3385
  // cross-scope display order is post-enrichment (central + every project by
3385
3386
  // rowid), which per-scope SQL LIMIT/OFFSET cannot compose, and (c) slicing the
3386
3387
  // deterministic cached array is O(page) to serialize — the real transfer win.
3387
- const _LIST_PAGE_MAX = 1000;
3388
+ // The bounds come from engine/api-contracts/paging.js — the SAME constants the
3389
+ // route catalog publishes on GET /api/routes, so the clamp and the advertised
3390
+ // contract can never drift (review of PR #1062).
3391
+ const _LIST_PAGE_MAX = listPaging.LIST_PAGE_MAX_LIMIT;
3392
+ const _LIST_PAGE_DEFAULT_LIMIT = listPaging.LIST_PAGE_DEFAULT_LIMIT;
3388
3393
  function _parsePageParams(req) {
3389
3394
  const params = new URL((req && req.url) || '/', 'http://localhost').searchParams;
3390
3395
  const hasLimit = params.has('limit');
@@ -3394,7 +3399,7 @@ function _parsePageParams(req) {
3394
3399
  const rawOffset = Number(params.get('offset'));
3395
3400
  const limit = hasLimit && Number.isFinite(rawLimit)
3396
3401
  ? Math.max(1, Math.min(_LIST_PAGE_MAX, Math.floor(rawLimit)))
3397
- : 50;
3402
+ : _LIST_PAGE_DEFAULT_LIMIT;
3398
3403
  const offset = hasOffset && Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0;
3399
3404
  return { limit, offset };
3400
3405
  }
@@ -9164,14 +9169,23 @@ const server = http.createServer(async (req, res) => {
9164
9169
  }
9165
9170
 
9166
9171
  async function handlePlansList(req, res) {
9172
+ let page;
9167
9173
  try {
9168
- planPrdValidation.validateNoQuery(req);
9174
+ // Only the optional limit/offset paging params are accepted; every other
9175
+ // query param is still rejected with the canonical 400, and the error
9176
+ // blames the REJECTED param rather than the accepted paging one
9177
+ // (W-ms3ovk83000rb60f). _parsePageParams returns null on the legacy
9178
+ // no-param path so the response stays byte-identical to legacy then.
9179
+ planPrdValidation.validateNoQuery(req, { allow: listPaging.LIST_PAGING_PARAMS });
9180
+ page = _parsePageParams(req);
9169
9181
  } catch (e) {
9170
9182
  return apiErrorReply(res, e, req);
9171
9183
  }
9172
9184
  const now = Date.now();
9173
9185
  if (_plansCache && (now - _plansCacheTs) < PLANS_CACHE_TTL_MS) {
9174
- return jsonReply(res, 200, _plansCache);
9186
+ // Keep caching the full array and slice per-request AFTER the cache read
9187
+ // so paging never corrupts the cache and `total` reflects the full set.
9188
+ return jsonReply(res, 200, page ? _paginateList(_plansCache, page) : _plansCache);
9175
9189
  }
9176
9190
  const fsp = fs.promises;
9177
9191
  // W-mrffmjaf002f08f0 — PRD JSON is SQL-authoritative (engine/prd-store.js);
@@ -9325,7 +9339,7 @@ const server = http.createServer(async (req, res) => {
9325
9339
  plans.sort((a, b) => (b.generatedAt || '').localeCompare(a.generatedAt || ''));
9326
9340
  _plansCache = plans;
9327
9341
  _plansCacheTs = Date.now();
9328
- return jsonReply(res, 200, plans);
9342
+ return jsonReply(res, 200, page ? _paginateList(plans, page) : plans);
9329
9343
  }
9330
9344
 
9331
9345
  async function handlePlansArchiveRead(req, res, match) {
@@ -12551,7 +12565,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12551
12565
  // ── Watches API Handlers ─────────────────────────────────────────────────
12552
12566
 
12553
12567
  async function handleWatchesList(req, res) {
12554
- return jsonReply(res, 200, { watches: watchesMod.getWatches() });
12568
+ const all = watchesMod.getWatches();
12569
+ // Optional progressive-load paging (W-ms3ovk83000rb60f). With no paging
12570
+ // params the response is byte-identical to the legacy `{ watches: [...] }`
12571
+ // shape; when a param is present we slice the SAME array and keep the
12572
+ // `watches` key (mapped from _paginateList's `items`) so existing consumers
12573
+ // keep working, adding total/hasMore/offset/limit alongside it.
12574
+ const page = _parsePageParams(req);
12575
+ if (!page) return jsonReply(res, 200, { watches: all });
12576
+ const { items, total, offset, limit, hasMore } = _paginateList(all, page);
12577
+ return jsonReply(res, 200, { watches: items, total, hasMore, offset, limit });
12555
12578
  }
12556
12579
 
12557
12580
  async function handleWatchesTargetTypes(req, res) {
@@ -14829,10 +14852,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14829
14852
  },
14830
14853
  });
14831
14854
  }},
14832
- { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt)', handler: (req, res) => {
14855
+ { method: 'GET', path: '/api/schedules', desc: 'Schedule definitions merged with SQL schedule-run state (_lastRun/_lastResult/_lastCompletedAt). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14856
+ const page = _parsePageParams(req);
14833
14857
  return serveFreshJson(req, res, {
14834
14858
  tag: 'schedules',
14835
14859
  inputs: [CONFIG_PATH],
14860
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14861
+ transform: page ? (list) => _paginateList(list, page) : null,
14836
14862
  builder: () => {
14837
14863
  // Read config.json directly via queries.getConfig() rather than
14838
14864
  // calling reloadConfig() — the latter cascades into
@@ -14857,12 +14883,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14857
14883
  },
14858
14884
  });
14859
14885
  }},
14860
- { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs', handler: (req, res) => {
14886
+ { method: 'GET', path: '/api/pipelines', desc: 'Pipeline definitions merged with last-5 SQL-backed runs. Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: (req, res) => {
14887
+ const page = _parsePageParams(req);
14861
14888
  return serveFreshJson(req, res, {
14862
14889
  tag: 'pipelines',
14863
14890
  inputs: [
14864
14891
  path.join(MINIONS_DIR, 'pipelines'),
14865
14892
  ],
14893
+ variant: page ? ('p' + page.offset + '.' + page.limit) : '',
14894
+ transform: page ? (list) => _paginateList(list, page) : null,
14866
14895
  builder: () => {
14867
14896
  try {
14868
14897
  const pl = require('./engine/pipeline');
@@ -15100,7 +15129,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15100
15129
 
15101
15130
  // Plans
15102
15131
  { method: 'POST', path: '/api/plan', desc: 'Create a plan work item that chains to PRD on completion', params: `title, description?, ${WORK_ITEM_PRIORITY_PARAM_HINT}, project? (string OR array for cross-repo plans), agent?, branch_strategy? or branchStrategy?`, handler: handlePlanCreate },
15103
- { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs)', handler: handlePlansList },
15132
+ { method: 'GET', path: '/api/plans', desc: 'List plan files (.md drafts + .json PRDs). Optional ?limit=&offset= returns a { items, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the full array is byte-identical to legacy.', handler: handlePlansList },
15104
15133
  { method: 'POST', path: '/api/plans/trigger-verify', desc: 'Manually trigger verification for a completed plan', params: 'file', handler: handlePlansTriggerVerify },
15105
15134
  { method: 'POST', path: '/api/plans/approve', desc: 'Approve a plan for execution', params: 'file, approvedBy?, forceRegen?, skipRegen?', handler: handlePlansApprove },
15106
15135
  { method: 'POST', path: '/api/plans/pause', desc: 'Pause a plan (stops materialization + resets active items)', params: 'file', handler: handlePlansPause },
@@ -15996,7 +16025,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15996
16025
  { method: 'POST', path: '/api/schedules/run-now', desc: 'Manually enqueue the work item for a schedule', params: 'id', handler: handleSchedulesRunNow },
15997
16026
 
15998
16027
  // Watches
15999
- { method: 'GET', path: '/api/watches', desc: 'List all watches', handler: handleWatchesList },
16028
+ { method: 'GET', path: '/api/watches', desc: 'List all watches. Optional ?limit=&offset= returns a wrapped { watches, total, hasMore, offset, limit } page (progressive load, W-ms3bbzry000i6612); with no paging params the response is byte-identical to legacy { watches: [...] }.', handler: handleWatchesList },
16000
16029
  { method: 'GET', path: '/api/watches/target-types', desc: 'List registered watch target types and their valid conditions', handler: handleWatchesTargetTypes },
16001
16030
  { method: 'GET', path: '/api/watches/action-types', desc: 'List registered follow-up action types (notify, dispatch-work-item, webhook, ...)', handler: handleWatchesActionTypes },
16002
16031
  { method: 'GET', path: /^\/api\/watches\/([\w-]+)\/history$/, template: '/api/watches/:id/history', desc: 'Read the persisted evaluation history (last 25 checks) for a watch', handler: handleWatchHistory },
@@ -97,7 +97,25 @@ by `test/unit/install-internal-minions.test.js`.
97
97
  (or already installed at the target version) *and* a state backup exists
98
98
  whenever there was a runtime to back up. Skipped with `--keep-public`, or when
99
99
  no public install is present.
100
- 6. **`install-internal`** — `npm install -g <package>@<resolved-version>` against the
100
+ 6. **`repair-shims`** — clears **orphaned** `minions` shims under `npm prefix -g`.
101
+ `npm uninstall -g` has just removed the shims it still owned, so anything left
102
+ under the prefix that dispatches into `@yemi33/minions` or
103
+ `@opg-microsoft/minions` with no usable package behind it is a leftover from a
104
+ prior uninstall or an interrupted migration. npm refuses to overwrite a shim it
105
+ does not own and aborts the whole global install with `EEXIST`, so the complete
106
+ npm-generated set is cleared here and `install-internal` recreates it. Ownership
107
+ must be *proven* — a shim is removed only when its body or symlink target names
108
+ one of those packages **and** that package's declared `bin` is no longer on
109
+ disk. An unrelated file named `minions` refuses the run (exit `3`) with its
110
+ exact path instead of being deleted, and an npm that reports no prefix or root
111
+ skips the repair entirely rather than guessing. `npm --force` is never used.
112
+ Shims are detected with `lstat`, not an existence check: on Linux and macOS
113
+ the orphan is usually a **dangling** `<prefix>/bin/minions` symlink whose
114
+ target went with the package, and a symlink-following check reports that
115
+ directory entry as absent while npm still aborts on it. Each deletion is
116
+ re-probed afterwards, so a delete that quietly no-ops is reported as a
117
+ failure with its path rather than as a cleared shim.
118
+ 7. **`install-internal`** — `npm install -g <package>@<resolved-version>` against the
101
119
  feed. **If that fails, the script installs the already-downloaded tarball**
102
120
  (`npm install -g <backup-dir>/<package>.tgz` — no registry, no token, so it
103
121
  still works when the feed is exactly what failed) rather than leaving the
@@ -107,29 +125,65 @@ by `test/unit/install-internal-minions.test.js`.
107
125
  `uninstall-public` is planned. `npm uninstall -g` takes the shim with the
108
126
  package that owns it, so skipping the install alongside an uninstall would
109
127
  remove the command with nothing to restore it.
110
- 7. **`verify-install`** — re-reads the installed `package.json` from disk and fails
128
+ 8. **`verify-install`** — re-reads the installed `package.json` from disk and fails
111
129
  loudly if npm did not actually land the resolved version.
112
- 8. **`verify-shim`** — resolves the global npm prefix and proves the `minions`
130
+ 9. **`verify-shim`** — resolves the global npm prefix and proves the `minions`
113
131
  command itself dispatches into the internal package (`minions.cmd` /
114
132
  `minions.ps1` / the sh shim on Windows, the `<prefix>/bin/minions` symlink
115
133
  elsewhere). Owning the package directory is not the same as owning the
116
134
  command: a half-finished cutover can leave `@opg-microsoft/minions` installed
117
135
  while `minions` still runs the public build. A shim still pointing at
118
- `@yemi33/minions` is a hard failure with the repair commands printed.
119
- 9. **`sync-init`** `minions init --force --skip-start`, the supported runtime
120
- synchronization. It overwrites `.js`/`.html` runtime files and preserves
121
- user-modified `.md` files.
122
- 10. **`restart`** `minions restart`, which is health-verified
136
+ `@yemi33/minions` is a hard failure with the repair commands printed, and so
137
+ is a shim whose link target no longer exists — naming the right package is
138
+ not the same as being able to run it.
139
+ 10. **`sync-init`** `minions init --force --skip-start`, the supported runtime
140
+ synchronization. It overwrites `.js`/`.html` runtime files and adds files the
141
+ new version requires. `--force` would otherwise also rewrite the shipped
142
+ `routing.md` and `knowledge/agents/*.md` with package defaults, so the
143
+ installer snapshots those before the sync and puts them back after it.
144
+ 11. **`restart`** — `minions restart`, which is health-verified
123
145
  (`engine/restart-health.js`: PID + HTTP probe). Skipped with `--no-restart`.
124
- 11. **`cleanup`** — always runs, on success and on every failure path. The
146
+ 12. **`cleanup`** — always runs, on success and on every failure path. The
125
147
  temporary npm config always goes; the downloaded artifact is retained (and its
126
148
  path printed) when the run ended with the machine still needing it.
127
149
 
150
+ Every prefix-sensitive npm command — `root -g`, `prefix -g`, `uninstall -g`,
151
+ `install -g`, and the shim inspection between them — runs against the **same**
152
+ `--userconfig`, so a custom global prefix (for example
153
+ `C:\.tools\.npm-global`, which usually comes from an `.npmrc` rather than a flag)
154
+ cannot be honoured by the install and ignored by the probe.
155
+
128
156
  After `sync-init` the script re-checks every runtime path that existed
129
157
  beforehand (`config.json`, `engine/state.db`, `notes/`, `notes.md`, `plans/`,
130
158
  `knowledge/`, `projects/`, `pinned.md`). A path that existed before and is
131
159
  missing after is a hard failure pointing at the backup, not a silent data loss.
132
160
 
161
+ ## What this is not
162
+
163
+ This is a **package-channel cutover**: it swaps the globally installed npm
164
+ package and leaves your runtime where it is. It is **not** a reset, not a
165
+ reinitialization, not an import/export migration, and it does not create a fresh
166
+ Minions home.
167
+
168
+ The runtime root is resolved **once**, before any package is touched
169
+ (`MINIONS_HOME`, otherwise `~/.minions`), and that exact path is pinned onto the
170
+ backup, the `init --force` sync, the verification, the restart, and any rollback.
171
+ Every one of the following survives a successful run, a failed run, a rollback,
172
+ and a re-run:
173
+
174
+ | State | Preserved how |
175
+ |---|---|
176
+ | `engine/state.db` — work items, dispatches, PR records, schedules, pipelines, watches, meetings, PRDs, QA state, small state | Never replaced. Checkpointed to the backup directory (`PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO`) before the package changes; the **live** database stays in place and the new version runs its own idempotent startup migrations against it. |
177
+ | `config.json` | `minions init` never overwrites it (`neverOverwrite` in `bin/minions.js`), and it is copied into the backup directory as well. |
178
+ | `routing.md`, `knowledge/agents/*.md` | Shipped by the package, so `init --force` *would* rewrite them. Snapshotted before `sync-init` and restored byte-for-byte after it. |
179
+ | `notes.md`, `notes/`, `pinned.md`, `plans/`, `prd/`, `projects/`, `agents/` | Not shipped by the package, so the sync never touches them. Existence is re-verified after the sync. |
180
+
181
+ The backup exists for **recovery only**. A failed install, verification, sync, or
182
+ shim check restores the *previous package* so `minions` keeps working — it never
183
+ restores state, never rolls your state backward, and never deletes state written
184
+ before or during the cutover.
185
+
186
+
133
187
  ## Token handling
134
188
 
135
189
  - The ADO token is acquired **per run** and is short-lived.
@@ -169,6 +223,11 @@ the temporary npm config is removed either way.
169
223
  Re-running is also the **repair** path: if a previous run left the internal
170
224
  package on disk while `minions` still resolved elsewhere (or nowhere), the plan
171
225
  reinstates `download-artifact` + `install-internal` so the shim is re-linked.
226
+ And if a previous run was interrupted between `npm uninstall -g` and
227
+ `npm install -g` — leaving `minions` / `minions.cmd` / `minions.ps1` behind with
228
+ no package to back them — `repair-shims` clears the orphans so the next run
229
+ converges instead of dying on `EEXIST`. See
230
+ [Orphaned `minions` shims](#orphaned-minions-shims-eexist).
172
231
 
173
232
  ## Relationship to `minions update`
174
233
 
@@ -203,7 +262,46 @@ the internal package on a machine that has no Minions at all.
203
262
  | `refusing to uninstall @yemi33/minions — …` | The artifact or the backup is missing | Nothing was removed. Re-run; the message names which precondition failed. |
204
263
  | `no way to back up the existing runtime state was found` | The runtime root has neither `engine/state-operations.js` nor a resolvable CLI | Nothing was uninstalled. The error lists every path that was probed; run `minions init --force` against the existing install to restore its engine files, then re-run. |
205
264
  | `the minions shim … still points at @yemi33/minions` | A previous half-finished cutover left the public shim in place | `npm uninstall -g @yemi33/minions`, then re-run. The install commands are printed with the error. |
206
- | Feed install failed after the public package was removed | Feed/network failure mid-install | Handled automatically the script reinstalls from the tarball it downloaded before the uninstall. If that also fails, the retained artifact path and the offline `npm install -g <tarball>` command are printed. |
265
+ | `npm ERR! EEXIST <prefix>\minions` on `npm install -g` | **Orphaned shim** a prior uninstall or interrupted migration removed the package directory but left the `minions` command behind, so nothing reported the old package as installed and npm refused to overwrite the leftover. | Handled automatically by `repair-shims`: just re-run the installer. Details below. |
266
+ | `REFUSED: … is named minions but was not generated by npm for …` | A file called `minions` under `npm prefix -g` could not be attributed to `@yemi33/minions` or `@opg-microsoft/minions` | The installer never deletes a file it cannot prove it owns. Inspect the named path, move or rename it (or remove it yourself if it is a leftover), then re-run. Nothing was modified. |
267
+ | Feed install failed after the public package was removed | Feed/network failure mid-install | Handled automatically — the script reinstalls from the tarball it downloaded before the uninstall. If that also fails, the retained artifact path and the offline `npm install -g <tarball>` command are printed, and the previous package is restored so `minions` keeps working. Your state is untouched. |
268
+
269
+ ### Orphaned `minions` shims (`EEXIST`)
270
+
271
+ The reported failure, from a Windows install whose npm global prefix is
272
+ `C:\.tools\.npm-global`:
273
+
274
+ ```
275
+ npm error code EEXIST
276
+ npm error path C:\.tools\.npm-global\minions
277
+ npm error EEXIST: file already exists
278
+ ```
279
+
280
+ `<npm root -g>\@yemi33\minions` had already been removed, but the shim trio
281
+ `minions`, `minions.cmd`, and `minions.ps1` was still sitting under
282
+ `npm prefix -g`. With no package metadata left to read, the installer saw nothing
283
+ installed, planned no `uninstall-public`, and npm collided with the leftover.
284
+
285
+ Inspect the state yourself with:
286
+
287
+ ```powershell
288
+ npm prefix -g
289
+ Get-ChildItem (npm prefix -g) -Filter 'minions*'
290
+ Get-ChildItem (Join-Path (npm root -g) '@yemi33'), (Join-Path (npm root -g) '@opg-microsoft') -ErrorAction SilentlyContinue
291
+ ```
292
+
293
+ ```bash
294
+ ls -l "$(npm prefix -g)/bin/minions"
295
+ ls -d "$(npm root -g)"/@yemi33/minions "$(npm root -g)"/@opg-microsoft/minions 2>/dev/null
296
+ ```
297
+
298
+ Re-running the installer is the fix — `repair-shims` clears the complete
299
+ npm-generated set so the install can recreate it. Nothing else about your install
300
+ is touched: the repair runs **after** the state backup, removes only files it has
301
+ proven npm generated for a Minions package that is no longer usable, and refuses
302
+ the run (exit `3`, nothing modified) rather than deleting an unrelated `minions`
303
+ executable. Your runtime state is never involved — see
304
+ [What this is not](#what-this-is-not).
207
305
 
208
306
  ## See also
209
307
 
@@ -4,6 +4,23 @@ const keepProcesses = require('../keep-process-sweep');
4
4
  const managedSpawn = require('../managed-spawn');
5
5
  const managedSpecNameMax = managedSpawn.validateManagedSpecName('x').maxLength;
6
6
  const agentApiValidation = require('../agent-api-validation');
7
+ const paging = require('./paging');
8
+
9
+ // The optional progressive-load paging window is the ONLY caller-supplied input
10
+ // these list endpoints read; unknown params are ignored (not rejected) because
11
+ // they never reach a validator.
12
+ function pagedListContract() {
13
+ return {
14
+ audit: 'audited',
15
+ query: paging.listPagingQuery(
16
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
17
+ ),
18
+ constraints: [
19
+ ...paging.listPagingClampConstraints(),
20
+ { kind: 'ignores-unknown-query-params', location: 'query' },
21
+ ],
22
+ };
23
+ }
7
24
 
8
25
  function absentOrEmptyOperatorBody() {
9
26
  return {
@@ -74,9 +91,12 @@ module.exports = {
74
91
  ],
75
92
  overrides: {
76
93
  'GET /api/keep-processes': { noInput: true },
77
- 'GET /api/schedules': { noInput: true },
78
- 'GET /api/pipelines': { noInput: true },
79
- 'GET /api/watches': { noInput: true },
94
+ // These three consume the optional ?limit=&offset= paging window
95
+ // (W-ms3ovk83000rb60f) they are NOT input-less, so the catalog must publish
96
+ // the real parameters instead of certifying them noInput.
97
+ 'GET /api/schedules': pagedListContract(),
98
+ 'GET /api/pipelines': pagedListContract(),
99
+ 'GET /api/watches': pagedListContract(),
80
100
  'GET /api/watches/target-types': { noInput: true },
81
101
  'GET /api/watches/action-types': { noInput: true },
82
102
  'GET /api/watches/<id>/history': {
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ // Single source of truth for the OPTIONAL progressive-load paging query surface
4
+ // (`?limit=&offset=`) shared by the large list endpoints.
5
+ //
6
+ // dashboard.js's `_parsePageParams` clamps incoming values with these bounds and
7
+ // the api-contract owner modules publish the same bounds on `GET /api/routes`,
8
+ // so the runtime behavior and the advertised contract cannot drift. Adding
9
+ // paging to another list endpoint means declaring `listPagingQuery()` on its
10
+ // contract — otherwise the route keeps certifying itself input-less while
11
+ // silently consuming caller input (review of PR #1062).
12
+
13
+ const LIST_PAGE_MAX_LIMIT = 1000;
14
+ const LIST_PAGE_DEFAULT_LIMIT = 50;
15
+ const LIST_PAGING_PARAMS = Object.freeze(['limit', 'offset']);
16
+
17
+ function listPagingQueryFields() {
18
+ return [
19
+ {
20
+ name: 'limit',
21
+ type: 'integer',
22
+ required: false,
23
+ min: 1,
24
+ max: LIST_PAGE_MAX_LIMIT,
25
+ default: LIST_PAGE_DEFAULT_LIMIT,
26
+ },
27
+ {
28
+ name: 'offset',
29
+ type: 'integer',
30
+ required: false,
31
+ min: 0,
32
+ default: 0,
33
+ },
34
+ ];
35
+ }
36
+
37
+ // `rationale` documents the per-route behavior for the params this contract does
38
+ // NOT list (rejected on /api/plans, ignored on the serveFreshJson endpoints).
39
+ function listPagingQuery(rationale) {
40
+ const query = { policy: 'optional', fields: listPagingQueryFields() };
41
+ if (rationale) query.rationale = String(rationale);
42
+ return query;
43
+ }
44
+
45
+ // Out-of-range/non-numeric paging values are clamped to the published bounds
46
+ // rather than rejected, so the clamp is part of the contract, not a 400 path.
47
+ function listPagingClampConstraints() {
48
+ return [
49
+ {
50
+ kind: 'clamped-limit',
51
+ field: 'limit',
52
+ location: 'query',
53
+ min: 1,
54
+ max: LIST_PAGE_MAX_LIMIT,
55
+ default: LIST_PAGE_DEFAULT_LIMIT,
56
+ },
57
+ {
58
+ kind: 'clamped-offset',
59
+ field: 'offset',
60
+ location: 'query',
61
+ min: 0,
62
+ default: 0,
63
+ },
64
+ ];
65
+ }
66
+
67
+ module.exports = {
68
+ LIST_PAGE_MAX_LIMIT,
69
+ LIST_PAGE_DEFAULT_LIMIT,
70
+ LIST_PAGING_PARAMS,
71
+ listPagingQueryFields,
72
+ listPagingQuery,
73
+ listPagingClampConstraints,
74
+ };
@@ -3,6 +3,7 @@
3
3
  const { PR_ACTIONS } = require('../pr-action');
4
4
  const { EXECUTION_TARGET_IDS } = require('../pr-fix-target');
5
5
  const { PR_FIX_CAUSE } = require('../shared');
6
+ const paging = require('./paging');
6
7
 
7
8
  const PR_REFERENCE_FORMATS = Object.freeze([
8
9
  'GitHub PR URL',
@@ -52,6 +53,15 @@ module.exports = {
52
53
  overrides: {
53
54
  'GET /api/pull-requests': {
54
55
  audit: 'audited',
56
+ // Consumes the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
57
+ // clamped rather than rejected — declared so the catalog publishes it.
58
+ query: paging.listPagingQuery(
59
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
60
+ ),
61
+ constraints: [
62
+ ...paging.listPagingClampConstraints(),
63
+ { kind: 'ignores-unknown-query-params', location: 'query' },
64
+ ],
55
65
  },
56
66
  'GET /api/prs/<id>': {
57
67
  audit: 'audited',
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const paging = require('./paging');
4
+
3
5
  const AUDITED_NEGATIVE_TESTS = Object.freeze([{
4
6
  strategy: 'isolated-http-invalid-input-state-snapshot',
5
7
  expectedStatus: '400-or-409',
@@ -57,7 +59,24 @@ module.exports = {
57
59
  query: { policy: 'none', rationale: 'Handler rejects any query string (planPrdValidation.validateNoQuery).' },
58
60
  },
59
61
  'GET /api/plans': {
60
- query: { policy: 'none', rationale: 'Handler rejects any query string (planPrdValidation.validateNoQuery).' },
62
+ audit: 'audited',
63
+ // Accepts the optional ?limit=&offset= paging window (W-ms3ovk83000rb60f);
64
+ // every OTHER query parameter is still rejected with the canonical 400 via
65
+ // planPrdValidation.validateNoQuery(req, { allow: LIST_PAGING_PARAMS }).
66
+ query: paging.listPagingQuery(
67
+ 'Optional progressive-load paging window; any other query parameter is rejected (planPrdValidation.validateNoQuery).',
68
+ ),
69
+ constraints: [
70
+ ...paging.listPagingClampConstraints(),
71
+ {
72
+ kind: 'reject-unknown-query-params',
73
+ location: 'query',
74
+ allowed: [...paging.LIST_PAGING_PARAMS],
75
+ },
76
+ ],
77
+ negativeTests: [
78
+ { strategy: 'reject-unknown-query-param-blaming-the-rejected-name', expectedStatus: 400 },
79
+ ],
61
80
  },
62
81
  'POST /api/work-items': {
63
82
  audit: 'audited',
@@ -248,10 +267,18 @@ module.exports = {
248
267
  },
249
268
  },
250
269
  'GET /api/work-items': {
251
- // Polled list endpoint: reads no query/body — returns the full slimmed
252
- // work-item collection. Affirmed input-less so it is audited rather than
253
- // defaulting to pending-audit by omission (review PR #966).
254
- noInput: true,
270
+ // Polled list endpoint. Reads no body/headers; the only caller-supplied
271
+ // input is the optional ?limit=&offset= paging window (W-ms3bbzry000i6612),
272
+ // which is clamped rather than rejected. Declared explicitly so the catalog
273
+ // publishes the real parameters instead of certifying the route input-less.
274
+ audit: 'audited',
275
+ query: paging.listPagingQuery(
276
+ 'Optional progressive-load paging window; any other query parameter is ignored, not rejected.',
277
+ ),
278
+ constraints: [
279
+ ...paging.listPagingClampConstraints(),
280
+ { kind: 'ignores-unknown-query-params', location: 'query' },
281
+ ],
255
282
  },
256
283
  'GET /api/work-items/archive': {
257
284
  // Archived-item listing: reads no query/body. Affirmed input-less.
@@ -65,11 +65,20 @@ function validateBody(body) {
65
65
  });
66
66
  }
67
67
 
68
- function validateNoQuery(req) {
68
+ // Reject caller-supplied query params. `options.allow` lists the params the
69
+ // endpoint genuinely accepts (e.g. the optional `limit`/`offset` paging window);
70
+ // those are filtered out BEFORE the offending name is chosen, so the message and
71
+ // the machine-readable `field`/`path` always blame a rejected param rather than
72
+ // an accepted one (review of PR #1062).
73
+ function validateNoQuery(req, options = {}) {
74
+ const allowed = new Set(Array.isArray(options.allow) ? options.allow : []);
69
75
  const parsed = new URL(req?.url || '/', 'http://localhost');
70
- const names = [...new Set(parsed.searchParams.keys())];
76
+ const names = [...new Set(parsed.searchParams.keys())].filter(name => !allowed.has(name));
71
77
  if (names.length > 0) {
72
- inputError(`This endpoint does not accept query parameters: ${names.join(', ')}`, {
78
+ const message = allowed.size > 0
79
+ ? `This endpoint only accepts the query parameters ${[...allowed].join(', ')}: ${names.join(', ')}`
80
+ : `This endpoint does not accept query parameters: ${names.join(', ')}`;
81
+ inputError(message, {
73
82
  code: 'unexpected-query',
74
83
  field: names[0],
75
84
  path: `query.${names[0]}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2445",
3
+ "version": "0.1.2447",
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"