@adaptic/maestro 1.4.1 → 1.5.1

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 +346 -53
  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,332 @@ 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();
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
+ }
740
+ }
741
+ return out;
742
+ }
672
743
 
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);
744
+ // .maestroignore gitignore-style allowlist of paths the upgrade must NOT
745
+ // touch. Supports comments (#), directory prefixes (trailing /), exact paths,
746
+ // simple globs (* matches a single path segment, ** matches recursively), and
747
+ // negation (! prefix to un-ignore). Match order is top-to-bottom; last match
748
+ // wins, like .gitignore.
749
+ function loadMaestroignore(cwd) {
750
+ const file = join(cwd, ".maestroignore");
751
+ if (!existsSync(file)) return null;
752
+ const patterns = [];
753
+ for (const raw of readFileSync(file, "utf-8").split(/\r?\n/)) {
754
+ const stripped = raw.replace(/^\s+|\s+$/g, "");
755
+ if (!stripped || stripped.startsWith("#")) continue;
756
+ const negate = stripped.startsWith("!");
757
+ const pat = negate ? stripped.slice(1).trim() : stripped;
758
+ if (!pat) continue;
759
+ patterns.push({ pat, negate });
676
760
  }
761
+ return patterns.length ? patterns : null;
762
+ }
677
763
 
678
- log("Upgrading framework files from @adaptic/maestro...");
764
+ function patternToRegex(pat) {
765
+ // Directory prefix: `scripts/daemon/` matches scripts/daemon/* (recursive)
766
+ if (pat.endsWith("/")) {
767
+ const prefix = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&");
768
+ return new RegExp("^" + prefix);
769
+ }
770
+ // Glob with * and **.
771
+ // Step order matters: ** must be captured before single * to preserve
772
+ // recursive semantics. * is intentionally NOT in the regex-escape char
773
+ // class so we can rewrite it after escaping the other specials.
774
+ if (pat.includes("*")) {
775
+ const escaped = pat
776
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
777
+ .replace(/\*\*/g, "<DBL>")
778
+ .replace(/\*/g, "[^/]*")
779
+ .replace(/<DBL>/g, ".*");
780
+ return new RegExp("^" + escaped + "$");
781
+ }
782
+ // Exact: also match anything underneath (a path like `scripts/daemon`
783
+ // without trailing slash should still protect the whole directory if
784
+ // it resolves to one — gitignore semantics).
785
+ const escaped = pat.replace(/[.+^${}()|[\]\\]/g, "\\$&");
786
+ return new RegExp("^" + escaped + "(/|$)");
787
+ }
679
788
 
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
- ];
789
+ function matchesIgnore(repoRel, patterns) {
790
+ if (!patterns) return false;
791
+ let ignored = false;
792
+ for (const { pat, negate } of patterns) {
793
+ if (patternToRegex(pat).test(repoRel)) {
794
+ ignored = !negate;
795
+ }
796
+ }
797
+ return ignored;
798
+ }
685
799
 
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++;
800
+ function isGitRepo(cwd) {
801
+ try {
802
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe" });
803
+ return true;
804
+ } catch { return false; }
805
+ }
806
+
807
+ // Build a Set of repo-relative paths that are dirty (modified, added, untracked).
808
+ // Single git invocation is much faster than per-file checks.
809
+ function dirtyPathSet(cwd) {
810
+ const set = new Set();
811
+ try {
812
+ const out = execFileSync(
813
+ "git",
814
+ ["status", "--porcelain=v1", "-z", "--untracked-files=all"],
815
+ { cwd, encoding: "utf-8" }
816
+ );
817
+ // -z separates records with NUL; each record is "XY path" (no quoting).
818
+ // For renames (R/C) the format is "R newpath\0oldpath\0" — we only care
819
+ // about destination, but for safety we treat both as dirty.
820
+ for (const rec of out.split("\0")) {
821
+ if (!rec) continue;
822
+ const status = rec.slice(0, 2);
823
+ const path = rec.slice(3);
824
+ // Anything in porcelain output is non-clean by definition.
825
+ if (status !== " ") set.add(path);
693
826
  }
827
+ } catch {
828
+ // Not a git repo or git failed — caller will fall back to caution mode.
829
+ }
830
+ return set;
831
+ }
832
+
833
+ function parseUpgradeFlags(args) {
834
+ const flags = {
835
+ dryRun: false,
836
+ forceOverwrite: false,
837
+ noIncoming: false,
838
+ verbose: false,
839
+ };
840
+ for (const a of args) {
841
+ if (a === "--dry-run" || a === "-n") flags.dryRun = true;
842
+ else if (a === "--force-overwrite" || a === "--force") flags.forceOverwrite = true;
843
+ else if (a === "--no-incoming") flags.noIncoming = true;
844
+ else if (a === "--verbose" || a === "-v") flags.verbose = true;
845
+ else if (a === "--help" || a === "-h") return null;
846
+ else { fail(`Unknown flag: ${a}`); process.exit(1); }
847
+ }
848
+ return flags;
849
+ }
850
+
851
+ function upgrade(args = []) {
852
+ const flags = parseUpgradeFlags(args);
853
+ if (flags === null) {
854
+ console.log(`
855
+ Usage: maestro upgrade [flags]
856
+
857
+ Flags:
858
+ --dry-run, -n Preview changes without writing
859
+ --force-overwrite Overwrite even locally-modified files (backs them up)
860
+ --no-incoming Don't write .maestro/incoming/ shadows for preserved files
861
+ --verbose, -v Print classification for every file
862
+ --help, -h Show this help
863
+
864
+ Per-file behaviour:
865
+ added — new upstream file → copy
866
+ updated — existed, byte-equal to your committed copy → safe overwrite
867
+ same — existed and already byte-identical to upstream → skip
868
+ ignored — matches a pattern in .maestroignore → never touched (any state)
869
+ preserved — has uncommitted local edits → keep yours, upstream lands at
870
+ .maestro/incoming/<path> for manual diff
871
+ mergeKept — under agents/ → never overwrite (custom agents preserved)
872
+ forced — overwritten by --force-overwrite; backup at .maestro/backup/<path>
873
+
874
+ .maestroignore format (gitignore-style, top-down, last match wins):
875
+ scripts/slack-send.sh exact file
876
+ scripts/daemon/ directory and everything beneath
877
+ scripts/send-email-*.py single-segment glob
878
+ workflows/** recursive glob
879
+ !workflows/quarterly/ un-ignore (force overwrite)
880
+ `);
881
+ return;
694
882
  }
695
883
 
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++;
884
+ const cwd = process.cwd();
885
+
886
+ if (!existsSync(join(cwd, "config/agent.ts")) && !existsSync(join(cwd, "CLAUDE.md"))) {
887
+ fail("Not a Maestro agent directory (config/agent.ts not found)");
888
+ process.exit(1);
702
889
  }
703
890
 
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++;
891
+ const inGit = isGitRepo(cwd);
892
+ if (!inGit) {
893
+ warn("Not in a git repo — cannot detect local modifications.");
894
+ warn("All existing files will be treated as locally modified and preserved.");
895
+ warn("Use --force-overwrite to override.");
710
896
  }
711
897
 
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++;
898
+ const dirty = inGit ? dirtyPathSet(cwd) : null;
899
+ const ignorePatterns = loadMaestroignore(cwd);
900
+ if (ignorePatterns) {
901
+ log(`.maestroignore loaded (${ignorePatterns.length} pattern${ignorePatterns.length === 1 ? "" : "s"})`);
718
902
  }
719
903
 
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++;
904
+ const banner = flags.dryRun ? "DRY RUN — " : "";
905
+ log(`${banner}Upgrading framework files from @adaptic/maestro...`);
906
+
907
+ const counts = { added: 0, updated: 0, same: 0, ignored: 0, preserved: 0, mergeKept: 0, forced: 0 };
908
+ const preservedFiles = [];
909
+ const ignoredFiles = [];
910
+
911
+ for (const { path: relRoot, mode } of UPGRADE_PATHS) {
912
+ const srcRoot = join(MAESTRO_ROOT, relRoot);
913
+ const dstRoot = join(cwd, relRoot);
914
+ if (!existsSync(srcRoot)) continue;
915
+
916
+ const srcFiles = walkFiles(srcRoot);
917
+ for (const srcFile of srcFiles) {
918
+ const relFromRoot = relative(srcRoot, srcFile);
919
+ const dstFile = join(dstRoot, relFromRoot);
920
+ const repoRel = relative(cwd, dstFile).split(sep).join("/");
921
+
922
+ const isIgnored = matchesIgnore(repoRel, ignorePatterns);
923
+
924
+ // Case 0: .maestroignore match — never touch, regardless of state.
925
+ // Checked BEFORE existsSync so that protected new-files-from-upstream
926
+ // are never silently introduced into the agent repo either.
927
+ if (isIgnored) {
928
+ counts.ignored++;
929
+ ignoredFiles.push(repoRel);
930
+ if (flags.verbose) console.log(` · ${repoRel} (ignored via .maestroignore)`);
931
+ continue;
932
+ }
933
+
934
+ // Case 1: new file (doesn't exist in agent) — always copy.
935
+ if (!existsSync(dstFile)) {
936
+ if (!flags.dryRun) {
937
+ mkdirSync(dirname(dstFile), { recursive: true });
938
+ copyFileSync(srcFile, dstFile);
939
+ }
940
+ counts.added++;
941
+ if (flags.verbose) ok(`+ ${repoRel}`);
942
+ continue;
943
+ }
944
+
945
+ // Case 2: byte-identical — nothing to do.
946
+ const srcHash = sha256File(srcFile);
947
+ const dstHash = sha256File(dstFile);
948
+ if (srcHash === dstHash) {
949
+ counts.same++;
950
+ if (flags.verbose) console.log(` = ${repoRel}`);
951
+ continue;
952
+ }
953
+
954
+ // Case 3: merge-mode never overwrites existing files.
955
+ if (mode === "merge") {
956
+ counts.mergeKept++;
957
+ if (flags.verbose) console.log(` ~ ${repoRel} (merge-mode, kept)`);
958
+ continue;
959
+ }
960
+
961
+ // Case 4: locally modified? Determine via git dirty set.
962
+ const locallyModified = inGit ? dirty.has(repoRel) : true;
963
+
964
+ if (locallyModified && !flags.forceOverwrite) {
965
+ counts.preserved++;
966
+ preservedFiles.push(repoRel);
967
+ if (!flags.dryRun && !flags.noIncoming) {
968
+ const shadow = join(cwd, ".maestro", "incoming", repoRel);
969
+ mkdirSync(dirname(shadow), { recursive: true });
970
+ copyFileSync(srcFile, shadow);
971
+ }
972
+ if (flags.verbose) console.log(` ~ ${repoRel} (local edits, preserved)`);
973
+ continue;
974
+ }
975
+
976
+ // Case 5: overwrite (clean tracked file, or --force).
977
+ if (locallyModified && flags.forceOverwrite) {
978
+ // Back up the about-to-be-clobbered file.
979
+ if (!flags.dryRun) {
980
+ const backup = join(cwd, ".maestro", "backup", repoRel);
981
+ mkdirSync(dirname(backup), { recursive: true });
982
+ copyFileSync(dstFile, backup);
983
+ }
984
+ counts.forced++;
985
+ if (flags.verbose) warn(`! ${repoRel} (forced; backup written)`);
986
+ } else {
987
+ counts.updated++;
988
+ if (flags.verbose) console.log(` > ${repoRel}`);
989
+ }
990
+ if (!flags.dryRun) {
991
+ mkdirSync(dirname(dstFile), { recursive: true });
992
+ copyFileSync(srcFile, dstFile);
993
+ }
994
+ }
726
995
  }
727
996
 
728
- // Create any missing directories from the expanded list
997
+ // Ensure standard runtime directories exist.
729
998
  const ensureDirs = [
730
999
  "state/handoffs", "state/huddle", "state/indexes", "state/rag",
731
1000
  "state/sessions", "state/slack-responded", "state/slack-thread-tracker",
@@ -739,15 +1008,33 @@ function upgrade() {
739
1008
  for (const dir of ensureDirs) {
740
1009
  const dirPath = join(cwd, dir);
741
1010
  if (!existsSync(dirPath)) {
742
- mkdirSync(dirPath, { recursive: true });
1011
+ if (!flags.dryRun) mkdirSync(dirPath, { recursive: true });
743
1012
  newDirs++;
744
1013
  }
745
1014
  }
746
- if (newDirs > 0) ok(`Created ${newDirs} new directories`);
1015
+
1016
+ // Summary
1017
+ console.log();
1018
+ console.log(`${C.bold}Upgrade summary${C.reset}${flags.dryRun ? " (dry run, nothing written)" : ""}`);
1019
+ if (counts.added) ok(`${counts.added} added (new files)`);
1020
+ if (counts.updated) ok(`${counts.updated} updated (vendored, no local edits)`);
1021
+ if (counts.same) console.log(` = ${counts.same} already up-to-date`);
1022
+ if (counts.ignored) console.log(` · ${counts.ignored} ignored (.maestroignore protected)`);
1023
+ if (counts.mergeKept) console.log(` ~ ${counts.mergeKept} merge-mode kept (agents/ custom files preserved)`);
1024
+ if (counts.preserved) warn(`${counts.preserved} preserved (local edits — kept your version)`);
1025
+ if (counts.forced) warn(`${counts.forced} force-overwritten (backups in .maestro/backup/)`);
1026
+ if (newDirs) ok(`${newDirs} new directories created`);
1027
+
1028
+ if (preservedFiles.length && !flags.noIncoming && !flags.dryRun) {
1029
+ console.log();
1030
+ log("Upstream versions of your locally-modified files saved to .maestro/incoming/");
1031
+ log("Review with: diff <path> .maestro/incoming/<path>");
1032
+ for (const p of preservedFiles.slice(0, 5)) console.log(` ${p}`);
1033
+ if (preservedFiles.length > 5) console.log(` … and ${preservedFiles.length - 5} more`);
1034
+ }
747
1035
 
748
1036
  console.log();
749
- ok(`Upgraded ${updated} framework components.`);
750
- log("Agent-specific files (config/, CLAUDE.md, knowledge/, memory/) were NOT modified.");
1037
+ log("Agent-specific paths (config/, CLAUDE.md, knowledge/, memory/, state/, outputs/, logs/, .env) were NOT touched.");
751
1038
  }
752
1039
 
753
1040
  // ---------------------------------------------------------------------------
@@ -820,16 +1107,22 @@ const [, , command, ...args] = process.argv;
820
1107
 
821
1108
  switch (command) {
822
1109
  case "create": create(args[0]); break;
823
- case "upgrade": upgrade(); break;
1110
+ case "upgrade": upgrade(args); break;
824
1111
  case "doctor": doctor(); break;
825
1112
  case "--help": case "-h": case undefined:
826
1113
  console.log(`
827
1114
  Maestro — Autonomous AI Agent Operating System
828
1115
 
829
1116
  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
1117
+ npx @adaptic/maestro create <dirname> Create a new agent repo
1118
+ npx @adaptic/maestro upgrade [--dry-run] Update framework files
1119
+ npx @adaptic/maestro doctor Verify installation
1120
+
1121
+ Upgrade flags:
1122
+ --dry-run, -n Preview changes without writing
1123
+ --force-overwrite Overwrite even locally-modified files (backs them up)
1124
+ --no-incoming Don't write .maestro/incoming/ shadows
1125
+ --verbose, -v List classification for every file
833
1126
 
834
1127
  Workflow:
835
1128
  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.1",
4
4
  "description": "Maestro — Autonomous AI agent operating system. Deploy AI employees on dedicated Mac minis.",
5
5
  "type": "module",
6
6
  "bin": {