@adaptic/maestro 1.4.1 → 1.5.0

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.
Files changed (2) hide show
  1. package/bin/maestro.mjs +257 -55
  2. package/package.json +1 -1
package/bin/maestro.mjs CHANGED
@@ -8,16 +8,21 @@
8
8
  * npx @adaptic/maestro doctor # Verify installation and configuration
9
9
  */
10
10
 
11
- import { resolve, join, dirname } from "node:path";
11
+ import { resolve, join, dirname, relative, sep } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import {
14
14
  mkdirSync,
15
15
  cpSync,
16
+ copyFileSync,
16
17
  existsSync,
17
18
  readFileSync,
18
19
  writeFileSync,
20
+ readdirSync,
21
+ statSync,
22
+ lstatSync,
19
23
  } from "node:fs";
20
24
  import { execFileSync } from "node:child_process";
25
+ import { createHash } from "node:crypto";
21
26
 
22
27
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
28
  const MAESTRO_ROOT = resolve(__dirname, "..");
@@ -664,68 +669,242 @@ rubrics: []
664
669
  }
665
670
 
666
671
  // ---------------------------------------------------------------------------
667
- // UPGRADE — update framework files in current agent repo
672
+ // UPGRADE — update framework files in current agent repo (smart merge)
668
673
  // ---------------------------------------------------------------------------
674
+ //
675
+ // Upgrade philosophy: by default, never silently overwrite a file the user has
676
+ // modified locally. Detection uses git as the source of truth:
677
+ //
678
+ // • untracked or has-uncommitted-changes in agent repo ⇒ "locally modified"
679
+ // • tracked and clean against HEAD ⇒ "vendored, safe to overwrite"
680
+ //
681
+ // For every framework file we'd otherwise overwrite, we classify into:
682
+ //
683
+ // added — file doesn't exist in agent repo → copy
684
+ // updated — file exists, agent's copy matches HEAD (no local edits) → overwrite
685
+ // same — file exists, content already byte-identical to upstream → skip
686
+ // preserved — file exists with local edits → keep agent's copy, write the
687
+ // upstream version to .maestro/incoming/<path> for manual review
688
+ // (unless --force-overwrite is passed)
689
+ //
690
+ // Flags:
691
+ // --dry-run preview only; no files written
692
+ // --force-overwrite overwrite even locally-modified files (with backup)
693
+ // --no-incoming skip writing .maestro/incoming/ shadows
694
+ // --verbose list every file's classification
695
+
696
+ // Paths that get upgraded. Each entry is { path, mode } where mode is:
697
+ // "smart" — per-file classification described above
698
+ // "merge" — add new files only; never overwrite existing (used for agents/)
699
+ const UPGRADE_PATHS = [
700
+ { path: "scripts", mode: "smart" },
701
+ { path: "policies", mode: "smart" },
702
+ { path: "docs", mode: "smart" },
703
+ { path: "public/assets", mode: "smart" },
704
+ { path: "workflows", mode: "smart" },
705
+ { path: "schedules", mode: "smart" },
706
+ { path: "desktop-control", mode: "smart" },
707
+ { path: "ingest", mode: "smart" },
708
+ { path: "mcp", mode: "smart" },
709
+ { path: ".claude/commands", mode: "smart" },
710
+ { path: "plugins/maestro-skills", mode: "smart" },
711
+ { path: "teams", mode: "smart" },
712
+ { path: "agents", mode: "merge" },
713
+ ];
714
+
715
+ function sha256File(p) {
716
+ return createHash("sha256").update(readFileSync(p)).digest("hex");
717
+ }
669
718
 
670
- function upgrade() {
671
- const cwd = process.cwd();
672
-
673
- if (!existsSync(join(cwd, "config/agent.ts")) && !existsSync(join(cwd, "CLAUDE.md"))) {
674
- fail("Not a Maestro agent directory (config/agent.ts not found)");
675
- process.exit(1);
719
+ function walkFiles(root) {
720
+ const out = [];
721
+ if (!existsSync(root)) return out;
722
+ const stack = [root];
723
+ while (stack.length) {
724
+ const dir = stack.pop();
725
+ for (const name of readdirSync(dir)) {
726
+ const full = join(dir, name);
727
+ // Use lstat first so we can detect (and skip) symlinks — particularly
728
+ // broken ones, which are common in shared scaffolds.
729
+ let lst;
730
+ try { lst = lstatSync(full); }
731
+ catch { continue; }
732
+ if (lst.isSymbolicLink()) {
733
+ // Skip symlinks entirely — agents shouldn't inherit links into paths
734
+ // that may not exist on the target machine.
735
+ continue;
736
+ }
737
+ if (lst.isDirectory()) stack.push(full);
738
+ else if (lst.isFile()) out.push(full);
739
+ }
676
740
  }
741
+ return out;
742
+ }
677
743
 
678
- log("Upgrading framework files from @adaptic/maestro...");
679
-
680
- // Framework directories that get overwritten on upgrade
681
- const frameworkDirs = [
682
- "scripts", "policies", "docs", "public/assets",
683
- "workflows", "schedules", "desktop-control", "ingest", "mcp",
684
- ];
744
+ function isGitRepo(cwd) {
745
+ try {
746
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe" });
747
+ return true;
748
+ } catch { return false; }
749
+ }
685
750
 
686
- let updated = 0;
687
- for (const dir of frameworkDirs) {
688
- const src = join(MAESTRO_ROOT, dir);
689
- if (existsSync(src)) {
690
- cpSync(src, join(cwd, dir), { recursive: true, force: true });
691
- ok(dir);
692
- updated++;
751
+ // Build a Set of repo-relative paths that are dirty (modified, added, untracked).
752
+ // Single git invocation is much faster than per-file checks.
753
+ function dirtyPathSet(cwd) {
754
+ const set = new Set();
755
+ try {
756
+ const out = execFileSync(
757
+ "git",
758
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
759
+ { cwd, encoding: "utf-8" }
760
+ );
761
+ // -z separates records with NUL; each record is "XY path" (no quoting).
762
+ // For renames (R/C) the format is "R newpath\0oldpath\0" — we only care
763
+ // about destination, but for safety we treat both as dirty.
764
+ for (const rec of out.split("\0")) {
765
+ if (!rec) continue;
766
+ const status = rec.slice(0, 2);
767
+ const path = rec.slice(3);
768
+ // Anything in porcelain output is non-clean by definition.
769
+ if (status !== " ") set.add(path);
693
770
  }
771
+ } catch {
772
+ // Not a git repo or git failed — caller will fall back to caution mode.
694
773
  }
774
+ return set;
775
+ }
695
776
 
696
- // .claude/commands (NOT settings.json — that's agent-specific)
697
- const commandsSrc = join(MAESTRO_ROOT, ".claude", "commands");
698
- if (existsSync(commandsSrc)) {
699
- cpSync(commandsSrc, join(cwd, ".claude", "commands"), { recursive: true, force: true });
700
- ok(".claude/commands");
701
- updated++;
777
+ function parseUpgradeFlags(args) {
778
+ const flags = {
779
+ dryRun: false,
780
+ forceOverwrite: false,
781
+ noIncoming: false,
782
+ verbose: false,
783
+ };
784
+ for (const a of args) {
785
+ if (a === "--dry-run" || a === "-n") flags.dryRun = true;
786
+ else if (a === "--force-overwrite" || a === "--force") flags.forceOverwrite = true;
787
+ else if (a === "--no-incoming") flags.noIncoming = true;
788
+ else if (a === "--verbose" || a === "-v") flags.verbose = true;
789
+ else if (a === "--help" || a === "-h") return null;
790
+ else { fail(`Unknown flag: ${a}`); process.exit(1); }
702
791
  }
792
+ return flags;
793
+ }
703
794
 
704
- // Plugins
705
- const pluginSrc = join(MAESTRO_ROOT, "plugins", "maestro-skills");
706
- if (existsSync(pluginSrc)) {
707
- cpSync(pluginSrc, join(cwd, "plugins", "maestro-skills"), { recursive: true, force: true });
708
- ok("plugins/maestro-skills");
709
- updated++;
795
+ function upgrade(args = []) {
796
+ const flags = parseUpgradeFlags(args);
797
+ if (flags === null) {
798
+ console.log(`
799
+ Usage: maestro upgrade [flags]
800
+
801
+ Flags:
802
+ --dry-run, -n Preview changes without writing
803
+ --force-overwrite Overwrite even locally-modified files (backs them up)
804
+ --no-incoming Don't write .maestro/incoming/ shadows for preserved files
805
+ --verbose, -v Print classification for every file
806
+ --help, -h Show this help
807
+ `);
808
+ return;
710
809
  }
711
810
 
712
- // Agent definitions (merge: add new agents, update existing, don't delete custom ones)
713
- const agentsSrc = join(MAESTRO_ROOT, "agents");
714
- if (existsSync(agentsSrc)) {
715
- cpSync(agentsSrc, join(cwd, "agents"), { recursive: true, force: false });
716
- ok("agents (merged, custom agents preserved)");
717
- updated++;
811
+ const cwd = process.cwd();
812
+
813
+ if (!existsSync(join(cwd, "config/agent.ts")) && !existsSync(join(cwd, "CLAUDE.md"))) {
814
+ fail("Not a Maestro agent directory (config/agent.ts not found)");
815
+ process.exit(1);
816
+ }
817
+
818
+ const inGit = isGitRepo(cwd);
819
+ if (!inGit) {
820
+ warn("Not in a git repo — cannot detect local modifications.");
821
+ warn("All existing files will be treated as locally modified and preserved.");
822
+ warn("Use --force-overwrite to override.");
718
823
  }
719
824
 
720
- // Teams
721
- const teamsSrc = join(MAESTRO_ROOT, "teams");
722
- if (existsSync(teamsSrc)) {
723
- cpSync(teamsSrc, join(cwd, "teams"), { recursive: true, force: true });
724
- ok("teams");
725
- updated++;
825
+ const dirty = inGit ? dirtyPathSet(cwd) : null;
826
+
827
+ const banner = flags.dryRun ? "DRY RUN — " : "";
828
+ log(`${banner}Upgrading framework files from @adaptic/maestro...`);
829
+
830
+ const counts = { added: 0, updated: 0, same: 0, preserved: 0, mergeKept: 0, forced: 0 };
831
+ const preservedFiles = [];
832
+
833
+ for (const { path: relRoot, mode } of UPGRADE_PATHS) {
834
+ const srcRoot = join(MAESTRO_ROOT, relRoot);
835
+ const dstRoot = join(cwd, relRoot);
836
+ if (!existsSync(srcRoot)) continue;
837
+
838
+ const srcFiles = walkFiles(srcRoot);
839
+ for (const srcFile of srcFiles) {
840
+ const relFromRoot = relative(srcRoot, srcFile);
841
+ const dstFile = join(dstRoot, relFromRoot);
842
+ const repoRel = relative(cwd, dstFile).split(sep).join("/");
843
+
844
+ // Case 1: new file (doesn't exist in agent) — always copy.
845
+ if (!existsSync(dstFile)) {
846
+ if (!flags.dryRun) {
847
+ mkdirSync(dirname(dstFile), { recursive: true });
848
+ copyFileSync(srcFile, dstFile);
849
+ }
850
+ counts.added++;
851
+ if (flags.verbose) ok(`+ ${repoRel}`);
852
+ continue;
853
+ }
854
+
855
+ // Case 2: byte-identical — nothing to do.
856
+ const srcHash = sha256File(srcFile);
857
+ const dstHash = sha256File(dstFile);
858
+ if (srcHash === dstHash) {
859
+ counts.same++;
860
+ if (flags.verbose) console.log(` = ${repoRel}`);
861
+ continue;
862
+ }
863
+
864
+ // Case 3: merge-mode never overwrites existing files.
865
+ if (mode === "merge") {
866
+ counts.mergeKept++;
867
+ if (flags.verbose) console.log(` ~ ${repoRel} (merge-mode, kept)`);
868
+ continue;
869
+ }
870
+
871
+ // Case 4: locally modified? Determine via git dirty set.
872
+ const locallyModified = inGit ? dirty.has(repoRel) : true;
873
+
874
+ if (locallyModified && !flags.forceOverwrite) {
875
+ counts.preserved++;
876
+ preservedFiles.push(repoRel);
877
+ if (!flags.dryRun && !flags.noIncoming) {
878
+ const shadow = join(cwd, ".maestro", "incoming", repoRel);
879
+ mkdirSync(dirname(shadow), { recursive: true });
880
+ copyFileSync(srcFile, shadow);
881
+ }
882
+ if (flags.verbose) console.log(` ~ ${repoRel} (local edits, preserved)`);
883
+ continue;
884
+ }
885
+
886
+ // Case 5: overwrite (clean tracked file, or --force).
887
+ if (locallyModified && flags.forceOverwrite) {
888
+ // Back up the about-to-be-clobbered file.
889
+ if (!flags.dryRun) {
890
+ const backup = join(cwd, ".maestro", "backup", repoRel);
891
+ mkdirSync(dirname(backup), { recursive: true });
892
+ copyFileSync(dstFile, backup);
893
+ }
894
+ counts.forced++;
895
+ if (flags.verbose) warn(`! ${repoRel} (forced; backup written)`);
896
+ } else {
897
+ counts.updated++;
898
+ if (flags.verbose) console.log(` > ${repoRel}`);
899
+ }
900
+ if (!flags.dryRun) {
901
+ mkdirSync(dirname(dstFile), { recursive: true });
902
+ copyFileSync(srcFile, dstFile);
903
+ }
904
+ }
726
905
  }
727
906
 
728
- // Create any missing directories from the expanded list
907
+ // Ensure standard runtime directories exist.
729
908
  const ensureDirs = [
730
909
  "state/handoffs", "state/huddle", "state/indexes", "state/rag",
731
910
  "state/sessions", "state/slack-responded", "state/slack-thread-tracker",
@@ -739,15 +918,32 @@ function upgrade() {
739
918
  for (const dir of ensureDirs) {
740
919
  const dirPath = join(cwd, dir);
741
920
  if (!existsSync(dirPath)) {
742
- mkdirSync(dirPath, { recursive: true });
921
+ if (!flags.dryRun) mkdirSync(dirPath, { recursive: true });
743
922
  newDirs++;
744
923
  }
745
924
  }
746
- if (newDirs > 0) ok(`Created ${newDirs} new directories`);
925
+
926
+ // Summary
927
+ console.log();
928
+ console.log(`${C.bold}Upgrade summary${C.reset}${flags.dryRun ? " (dry run, nothing written)" : ""}`);
929
+ if (counts.added) ok(`${counts.added} added (new files)`);
930
+ if (counts.updated) ok(`${counts.updated} updated (vendored, no local edits)`);
931
+ if (counts.same) console.log(` = ${counts.same} already up-to-date`);
932
+ if (counts.mergeKept) console.log(` ~ ${counts.mergeKept} merge-mode kept (agents/ custom files preserved)`);
933
+ if (counts.preserved) warn(`${counts.preserved} preserved (local edits — kept your version)`);
934
+ if (counts.forced) warn(`${counts.forced} force-overwritten (backups in .maestro/backup/)`);
935
+ if (newDirs) ok(`${newDirs} new directories created`);
936
+
937
+ if (preservedFiles.length && !flags.noIncoming && !flags.dryRun) {
938
+ console.log();
939
+ log("Upstream versions of your locally-modified files saved to .maestro/incoming/");
940
+ log("Review with: diff <path> .maestro/incoming/<path>");
941
+ for (const p of preservedFiles.slice(0, 5)) console.log(` ${p}`);
942
+ if (preservedFiles.length > 5) console.log(` … and ${preservedFiles.length - 5} more`);
943
+ }
747
944
 
748
945
  console.log();
749
- ok(`Upgraded ${updated} framework components.`);
750
- log("Agent-specific files (config/, CLAUDE.md, knowledge/, memory/) were NOT modified.");
946
+ log("Agent-specific paths (config/, CLAUDE.md, knowledge/, memory/, state/, outputs/, logs/, .env) were NOT touched.");
751
947
  }
752
948
 
753
949
  // ---------------------------------------------------------------------------
@@ -820,16 +1016,22 @@ const [, , command, ...args] = process.argv;
820
1016
 
821
1017
  switch (command) {
822
1018
  case "create": create(args[0]); break;
823
- case "upgrade": upgrade(); break;
1019
+ case "upgrade": upgrade(args); break;
824
1020
  case "doctor": doctor(); break;
825
1021
  case "--help": case "-h": case undefined:
826
1022
  console.log(`
827
1023
  Maestro — Autonomous AI Agent Operating System
828
1024
 
829
1025
  Usage:
830
- npx @adaptic/maestro create <dirname> Create a new agent repo
831
- npx @adaptic/maestro upgrade Update framework files
832
- npx @adaptic/maestro doctor Verify installation
1026
+ npx @adaptic/maestro create <dirname> Create a new agent repo
1027
+ npx @adaptic/maestro upgrade [--dry-run] Update framework files
1028
+ npx @adaptic/maestro doctor Verify installation
1029
+
1030
+ Upgrade flags:
1031
+ --dry-run, -n Preview changes without writing
1032
+ --force-overwrite Overwrite even locally-modified files (backs them up)
1033
+ --no-incoming Don't write .maestro/incoming/ shadows
1034
+ --verbose, -v List classification for every file
833
1035
 
834
1036
  Workflow:
835
1037
  1. npx @adaptic/maestro create my-agent
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adaptic/maestro",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {