@mytegroupinc/myte-core 0.0.19 → 0.0.20

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 (3) hide show
  1. package/README.md +14 -142
  2. package/cli.js +525 -48
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,151 +1,23 @@
1
1
  # @mytegroupinc/myte-core
2
2
 
3
- Internal implementation package for the `myte` CLI.
3
+ Implementation package for the `myte` CLI.
4
4
 
5
- Most users should install the unscoped wrapper instead:
6
- - `npm install myte` then `npx myte ai "Explain this repository"`
7
- - `npm install myte` then `npx myte ai "Return a JSON object with risks and next_steps" --json-response`
8
- - `npm install myte` then `npx myte bootstrap`
9
- - `npm install myte` then `npx myte run-qaqc --mission-ids M001 --wait --sync`
10
- - `npm install myte` then `npx myte mission status --mission-ids M001 --status done`
11
- - `npm install myte` then `npx myte sync-qaqc`
12
- - `npm install myte` then `npx myte feedback-sync`
13
- - `npm install myte` then `npx myte suggestions sync`
14
- - `npm install myte` then `npx myte suggestions create`
15
- - `npm install myte` then `npx myte suggestions revise`
16
- - `npm install myte` then `npx myte suggestions review`
17
- - `npm install myte` then `npx myte query "..." --with-diff`
18
- - `npm install myte` then `npm exec myte -- query "..." --with-diff`
19
- - `npm install myte` then `npx myte update-team "Backend deploy completed; QAQC rerun queued."`
20
- - `npm install myte` then `npx myte update-owner --subject "QAQC progress" --body-file ./updates/owner.md`
21
- - `npm install myte` then `npx myte update-client --subject "Weekly client update" --body-file ./updates/week-12.md`
22
- - `npm i -g myte` then `myte bootstrap`
23
- - `npm i -g myte` then `myte run-qaqc --mission-ids M001 --wait --sync`
24
- - `npm i -g myte` then `myte mission status --mission-ids M001 --status done`
25
- - `npm i -g myte` then `myte sync-qaqc`
26
- - `npm i -g myte` then `myte feedback-sync`
27
- - `npm i -g myte` then `myte suggestions sync`
28
- - `npm i -g myte` then `myte suggestions create`
29
- - `npm i -g myte` then `myte suggestions revise`
30
- - `npm i -g myte` then `myte suggestions review`
31
- - `npm i -g myte` then `myte query "..." --with-diff`
32
- - `npm i -g myte` then `myte update-team "Backend deploy completed; QAQC rerun queued."`
33
- - `npm i -g myte` then `myte update-owner --subject "QAQC progress" --body-file ./updates/owner.md`
34
- - `npm i -g myte` then `myte update-client --subject "Weekly client update" --body-file ./updates/week-12.md`
35
- - `npx myte@latest bootstrap`
36
- - `npx myte@latest run-qaqc --mission-ids M001 --wait --sync`
37
- - `npx myte@latest mission status --mission-ids M001 --status done`
38
- - `npx myte@latest sync-qaqc`
39
- - `npx myte@latest feedback-sync`
40
- - `npx myte@latest suggestions sync`
41
- - `npx myte@latest suggestions create`
42
- - `npx myte@latest suggestions revise`
43
- - `npx myte@latest suggestions review`
44
- - `npx myte@latest query "..." --with-diff`
45
- - `npx myte@latest update-team "Backend deploy completed; QAQC rerun queued."`
46
- - `npx myte@latest update-owner --subject "QAQC progress" --body-file ./updates/owner.md`
47
- - `npx myte@latest update-client --subject "Weekly client update" --body-file ./updates/week-12.md`
48
- - `npm install myte` then `npx myte create-prd ./drafts/auth-prd.md`
49
- - `npm install myte` then `npx myte create-prd ./drafts/auth-prd.md ./drafts/billing-prd.md`
50
- - `cat ./drafts/auth-prd.md | npx myte create-prd --stdin`
5
+ Most users should install the public wrapper instead:
51
6
 
52
- Requirements:
53
- - Node `18+`
54
- - macOS, Linux, or Windows
55
- - `git` in `PATH` for `--with-diff`
56
- - `MYTEAI_API_KEY=<inference_api_key>` in env or `.env` for `myte ai`
57
- - `MYTE_API_KEY=<project_api_key>` in env or `.env`
58
- - repo folder names must match the project repo names configured in Myte, including casing on case-sensitive filesystems
7
+ - `npm install myte`
8
+ - run with `npx myte ...`
59
9
 
60
- Notes:
61
- - `npm install myte` installs the wrapper locally; use `npx myte` or `npm exec myte -- ...` to run it.
62
- - `npm install myte` means the CLI is available locally; bare `myte ...` still requires a global install.
63
- - `bootstrap` is a local file materialization path, not a hosted file download.
64
- - `bootstrap` expects to run from a wrapper root that contains the project's configured repo folders.
65
- - `bootstrap` writes `MyteCommandCenter/data/project.yml` plus itemized `phases`, `epics`, `stories`, and `missions`.
66
- - `bootstrap` materializes a public Command Center DTO, not raw backend documents.
67
- - `bootstrap` mission cards now include richer execution context like `complexity`, `estimated_hours`, `due_date`, `subtasks`, `technical_requirements`, `resources_needed`, `labels`, and normalized `test_cases`.
68
- - `bootstrap` excludes internal keys like `_id`, `org_id`, `project_id`, `created_by`, `assigned_to`, and raw `qa_qc_results`.
69
- - rerunning current commands on an older workspace automatically prunes legacy artifacts like `bootstrap-manifest.json`, `data/qaqc/`, and `data/feedback/` as the new files are written.
70
- - `run-qaqc` queues QAQC for up to 10 explicit mission ids through `/api/project-assistant/run-qaqc`.
71
- - On PowerShell, quote comma-separated multi-id values: `--mission-ids "M001,M002"`.
72
- - `run-qaqc --wait` polls `/api/project-assistant/run-qaqc/<batch_id>` until the batch is terminal.
73
- - `run-qaqc --sync` refreshes `MyteCommandCenter/data/qaqc.yml` after a completed batch.
74
- - `mission status` updates one or many mission business ids through `/api/project-assistant/mission-status-update`.
75
- - `mission status` normalizes status aliases like `todo`, `in_progress`, and `done`, then sends the canonical mission status values `Todo`, `In Progress`, or `Done`.
76
- - `mission status` updates only the mission `status` field used by the app. It does not run QAQC and it does not rewrite `MyteCommandCenter/data/qaqc.yml`.
77
- - project-key QAQC runs through the dedicated `project_api_qaqc` queue inside the existing Celery service, with a global budget of `20` dispatch starts/minute and `20` live jobs.
78
- - a saturated `run-qaqc --wait` batch can take roughly `5-10` minutes before `--sync` has final data to write.
79
- - `sync-qaqc` works without `bootstrap`; it creates `MyteCommandCenter/data/qaqc.yml` automatically if missing.
80
- - `sync-qaqc` writes the active mission QAQC working set to `MyteCommandCenter/data/qaqc.yml`.
81
- - `sync-qaqc` only exports active `Todo` / `In Progress` missions plus a public QAQC summary and sanitized latest batch metadata.
82
- - `sync-qaqc` keeps QAQC state in one deterministic file so the working set grows and shrinks with current active-mission reality.
83
- - `sync-qaqc` fully rewrites `MyteCommandCenter/data/qaqc.yml` on every sync and does not delete `MyteCommandCenter/data/missions/*.yml`.
84
- - `feedback-sync` writes one deterministic feedback snapshot under `MyteCommandCenter/data/feedback.yml`.
85
- - `feedback-sync` defaults to pending feedback unless `--status` is provided.
86
- - `feedback-sync` keeps feedback metadata plus comment turns in `MyteCommandCenter/data/feedback.yml`.
87
- - `feedback-sync` writes full PRD context into `MyteCommandCenter/PRD/feedback-sync/*.md` and points to those files from `feedback.yml`.
88
- - `feedback-sync` fully replaces the feedback-owned sync file to avoid stale local feedback noise.
89
- - `feedback-sync` fully rewrites `MyteCommandCenter/data/feedback.yml` on every sync and does not delete `MyteCommandCenter/data/missions/*.yml`.
90
- - `suggestions sync` writes one merge-safe workflow file at `MyteCommandCenter/data/mission-ops.yml`.
91
- - `suggestions sync` should be the first step before ideating locally so new submissions start from the latest aggregated thread state.
92
- - `suggestions sync` rewrites the canonical synced state into `threads[]` and preserves top-level `workspace.<actor_scope>` blocks plus per-thread `workspace.<actor_scope>` drafts from the existing file.
93
- - synced `threads[]` include aggregate diffs, conflict summaries, and archived decision lineage so local agents do not need to reconstruct review state.
94
- - `suggestions create` reads a structured file payload or local `workspace.<actor_scope>.draft_submissions[]` blocks from `mission-ops.yml`.
95
- - `suggestions revise` reads structured file payloads or local per-thread `workspace.<actor_scope>` drafts only when `draft_status` is `submit`, `ready`, or `pending_submit`.
96
- - `suggestions review` is owner-only, reads structured file payloads or local per-thread owner review intent, and refreshes `data/missions/*.yml` when approvals apply changes.
97
- - `suggestions review` requires an explicit `review_action` of `request_changes`, `approve`, or `reject`; there is no persisted save-draft review action.
98
- - suggestion notifications deep-link into the project Reviews workspace for the affected thread.
99
- - create/revise/review resync `mission-ops.yml` by default unless `--no-sync` is passed.
100
- - create/revise/review automatically send `X-Idempotency-Key` and a default `client_session_id` so retries stay deterministic and auditable.
101
- - `--print-context` prints the JSON payload that would be submitted for create/revise/review.
102
- - `mission-ops.yml` keeps synced thread lineage plus actor-local draft space in one file; terminal items fall out of `queue` after sync but remain in `threads[]`.
103
- - `create-prd` is the deterministic PRD upload path, not an LLM generation command.
104
- - `update-team` creates a project comment through `/api/project-assistant/project-comment`.
105
- - `update-owner` sends a direct owner email through `/api/project-assistant/update-owner`.
106
- - `update-owner` requires `--subject` plus body markdown from `--body-markdown`, `--body-file`, positional input, or `--stdin`.
107
- - `update-client` creates a client update draft through `/api/project-assistant/client-update-drafts`.
108
- - `update-client` requires `--subject` plus body markdown from `--body-markdown`, `--body-file`, positional input, or `--stdin`.
109
- - `update-client` accepts optional `--target-contact-id` repeats or `--target-contact-ids <id1,id2>`.
110
- - If no linked client contacts exist, the backend falls back to the project owner for internal projects.
111
- - `--with-diff` only searches repo folders whose names match the project repo names configured in Myte.
112
- - `--with-diff` includes per-repo diagnostics in `print-context` payload:
113
- - missing repo directories
114
- - per-repo errors (for example fetch or command failures)
115
- - clean/no-change repo summaries
116
- - `--with-diff` query payload includes `diff_diagnostics` so backend/UI can report exactly why context may be missing.
10
+ This package exists so the public wrapper can stay small and versioned cleanly. It is not the recommended public install target.
117
11
 
118
- Deterministic `create-prd` contract:
119
- - Required: `MYTE_API_KEY`, a PRD markdown body, and a title.
120
- - Accepts one file or many files per command. The CLI uses `/project-assistant/create-prd` for one item and automatically uses the batch upload path for multi-file requests.
121
- - Title source: `myte-kanban.title`, the first markdown `# Heading`, or `--title`.
122
- - Description source: `myte-kanban.description` or `--description`.
123
- - The markdown body (`prd_markdown`, or the body portion of `ticket_markdown`) is stored verbatim as PRD content and is what the backend uses to build the PRD DOCX.
124
- - Legacy `feedback_text` is still accepted for older payloads, but new callers should use `description`.
125
- - Optional structured fields: `priority`, `status`, `tags`, `assigned_user_email`, `assigned_user_id`, `due_date`, `repo_name`, `repo_id`, `preview_url`, `source`.
12
+ ## Requirements
126
13
 
127
- Examples:
128
- - `npx myte ai "Explain what this project does"`
129
- - `npx myte ai "Return a JSON object with risks and next_steps" --json-response`
130
- - `npx myte bootstrap`
131
- - `npx myte run-qaqc --mission-ids "M001,M002" --wait --sync`
132
- - `npx myte mission status --mission-ids "M001,M002" --status done`
133
- - `npx myte sync-qaqc`
134
- - `npx myte feedback-sync`
135
- - `npx myte suggestions sync`
136
- - `npx myte suggestions create --file ./changes/create.yml`
137
- - `npx myte suggestions create`
138
- - `npx myte suggestions revise`
139
- - `npx myte suggestions review`
140
- - `npx myte bootstrap --dry-run --json`
141
- - `npx myte sync-qaqc --dry-run --json`
142
- - `npx myte create-prd ./drafts/auth-prd.md --description "Short card summary"`
143
- - `npx myte create-prd ./drafts/auth-prd.md ./drafts/billing-prd.md`
144
- - `npx myte create-prd ./drafts/auth-prd.md --print-context`
145
- - `npx myte update-team "Backend deploy completed; QAQC rerun queued."`
146
- - `npx myte update-owner --subject "QAQC progress" --body-file ./updates/owner.md`
147
- - `npx myte update-client --subject "Weekly client update" --body-file ./updates/week-12.md`
14
+ - Node `18+`
15
+ - `MYTE_API_KEY` for project-scoped commands
16
+ - `MYTEAI_API_KEY` for `myte ai`
17
+ - `git` only when using `query --with-diff`
148
18
 
149
- This package is published under the org scope for governance; the public `myte` wrapper delegates here.
19
+ ## Behavior Summary
150
20
 
151
- `--json-response` sends both the Myte convenience flag (`myte_json_response: true`) and the OpenAI-compatible chat-completions shape (`response_format: { type: "json_object" }`). On the public developer API, that strict-JSON path also defaults to `medium` reasoning unless the caller explicitly overrides reasoning.
21
+ - Snapshot-style commands such as `bootstrap`, `sync-qaqc`, `feedback-sync`, and `suggestions sync` write local `MyteCommandCenter` data.
22
+ - `query --with-diff` requires project repos to be configured for diff collection and fails fast when no matching local project repo can be resolved.
23
+ - Public package documentation is intentionally minimal. Internal rollout and design notes are not part of the npm package contract.
package/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * - Auth: MYTE_API_KEY (project key) from `.env` or env
6
6
  * - Default API: https://api.myte.dev (override with MYTE_API_BASE or --base-url)
7
- * - Deterministic diffs: fetch /api/project-assistant/config to get repo folder names
7
+ * - Deterministic diffs: fetch project config, resolve local project repos, then collect scoped git context
8
8
  */
9
9
 
10
10
  const fs = require("fs");
@@ -194,7 +194,7 @@ function parseArgs(argv) {
194
194
 
195
195
  function printHelp() {
196
196
  const text = [
197
- "myte - Myte Project Assistant CLI",
197
+ "myte - Myte CLI",
198
198
  "",
199
199
  "Usage:",
200
200
  " myte query \"<text>\" [--with-diff] [--context \"...\"]",
@@ -232,13 +232,13 @@ function printHelp() {
232
232
  " - Set MYTEAI_API_KEY in a workspace .env (or env var) for `myte ai`",
233
233
  "",
234
234
  "bootstrap contract:",
235
- " - Run from the wrapper root that contains the project's configured repo folders",
235
+ " - Run from any workspace where you want local MyteCommandCenter data written",
236
236
  " - Writes MyteCommandCenter/data/project.yml plus phases, epics, stories, and missions locally",
237
237
  " - Uses the project-scoped bootstrap snapshot from the Myte API",
238
238
  " - Mission cards include richer execution context like complexity, estimated_hours, due_date, subtasks, technical_requirements, resources_needed, labels, and normalized test_cases",
239
239
  "",
240
240
  "sync-qaqc contract:",
241
- " - Run from the wrapper root that contains the project's configured repo folders",
241
+ " - Run from any workspace where you want local MyteCommandCenter data written",
242
242
  " - Works even if bootstrap has not been run yet; it creates MyteCommandCenter/data/qaqc.yml",
243
243
  " - Writes the active mission QAQC working set into one deterministic file: MyteCommandCenter/data/qaqc.yml",
244
244
  "",
@@ -285,13 +285,13 @@ function printHelp() {
285
285
  " - If the project has no linked client contacts, the backend falls back to the project owner for internal projects",
286
286
  "",
287
287
  "feedback-sync contract:",
288
- " - Runs from the wrapper root that contains the project's configured repo folders",
288
+ " - Runs from any workspace where you want local MyteCommandCenter data written",
289
289
  " - Syncs pending feedback by default so local Command Center data stays focused on active work",
290
290
  " - Writes project feedback metadata and conversation turns into MyteCommandCenter/data/feedback.yml",
291
291
  " - Stores full PRD context in MyteCommandCenter/PRD/feedback-sync/*.md and points to those files from feedback.yml",
292
292
  "",
293
293
  "Options:",
294
- " --with-diff Include deterministic git diffs (project-scoped)",
294
+ " --with-diff Include deterministic git diffs (project-scoped; fails fast if no project repos are configured or resolved)",
295
295
  " --diff-limit <chars> Truncate diff context to N chars (default: 200000)",
296
296
  " --timeout-ms <ms> Request timeout (default: 300000)",
297
297
  " --base-url <url> API base (default: https://api.myte.dev)",
@@ -299,7 +299,7 @@ function printHelp() {
299
299
  " --json-response Ask the Myte AI gateway to return clean JSON only and send OpenAI-compatible response_format",
300
300
  " --max-output-tokens Output token cap for `myte ai` simple queries",
301
301
  " --temperature <num> Temperature for `myte ai` simple queries",
302
- " --output-dir <path> Command Center output directory (default: <wrapper-root>/MyteCommandCenter)",
302
+ " --output-dir <path> Command Center output directory (default: <current-workspace>/MyteCommandCenter)",
303
303
  " --file <path> YAML/JSON payload file for suggestions create/revise/review",
304
304
  " --stdin Read supported command content from stdin instead of inline text or a file path",
305
305
  " --title <text> Override PRD title for raw markdown uploads",
@@ -589,18 +589,25 @@ function summarizeDiffDiagnosticsForContext(diagnostics) {
589
589
  project_id: diagnostics.project_id || null,
590
590
  mode: diagnostics.mode,
591
591
  requested_repos: diagnostics.requested_repo_names || [],
592
+ requested_repo_bindings: diagnostics.requested_repo_bindings || [],
592
593
  found_repos: diagnostics.found_repos || [],
593
594
  missing_repos: diagnostics.missing_repos || [],
594
595
  collected_any: Boolean(diagnostics.collected_any),
595
596
  truncation: diagnostics.truncated ? "truncated" : "full",
596
597
  repos: repos.map((repo) => ({
597
598
  name: repo.name,
599
+ role: repo.role || null,
598
600
  status: repo.status || "ok",
599
601
  head_branch: repo.head_branch || null,
600
602
  base_ref: repo.base_ref || null,
603
+ base_ref_label: repo.base_ref_label || repo.base_ref || null,
604
+ baseline_branch: repo.baseline_branch || null,
605
+ compare_mode: repo.compare_mode || "local_origin",
606
+ compare_remote_url: repo.compare_remote_url || null,
601
607
  has_changes: Boolean(repo.has_changes),
602
608
  changed_blocks: repo.changed_blocks || {},
603
609
  untracked_file_count: repo.untracked_file_count || 0,
610
+ matched_by: repo.matched_by || [],
604
611
  error_count: Array.isArray(repo.errors) ? repo.errors.length : 0,
605
612
  })),
606
613
  warnings: diagnostics.warnings || [],
@@ -642,8 +649,29 @@ const IGNORED_PATTERNS = [
642
649
  /^out\//,
643
650
  ];
644
651
 
652
+ const DIFF_MARKDOWN_ALLOWLIST = new Set([
653
+ "README.md",
654
+ "AGENTS.md",
655
+ "CodexContext.MD",
656
+ "CLAUDE.md",
657
+ "GEMINI.md",
658
+ ]);
659
+
660
+ function normalizeRepoPathForDiff(p) {
661
+ return String(p || "").replace(/\\/g, "/");
662
+ }
663
+
664
+ function shouldIgnoreMarkdownPath(p) {
665
+ const normalized = normalizeRepoPathForDiff(p);
666
+ if (!/\.md$/i.test(normalized)) return false;
667
+ const base = path.posix.basename(normalized);
668
+ return !DIFF_MARKDOWN_ALLOWLIST.has(base);
669
+ }
670
+
645
671
  function shouldIgnore(p) {
646
- return IGNORED_PATTERNS.some((re) => re.test(p));
672
+ const normalized = normalizeRepoPathForDiff(p);
673
+ if (shouldIgnoreMarkdownPath(normalized)) return true;
674
+ return IGNORED_PATTERNS.some((re) => re.test(normalized));
647
675
  }
648
676
 
649
677
  function hasGitDir(repoPath) {
@@ -833,12 +861,359 @@ function resolveConfiguredRepos(repoNames) {
833
861
  return { mode: "none", root: null, repos: [], missing: names };
834
862
  }
835
863
 
836
- function fetchBaseBranches(repoPath) {
864
+ function findGitTopLevel(startPath) {
865
+ const result = runGitRaw(startPath, ["rev-parse", "--show-toplevel"]);
866
+ if (!result.ok) return null;
867
+ const value = String(result.stdout || "").trim();
868
+ return value || null;
869
+ }
870
+
871
+ function normalizeRemoteFingerprint(value) {
872
+ const raw = String(value || "").trim();
873
+ if (!raw) return "";
874
+
875
+ const cleaned = raw.replace(/\\/g, "/").replace(/\.git$/i, "");
876
+ const looksLikeLocalPath =
877
+ !cleaned.includes("://") &&
878
+ (/^[a-zA-Z]:\//.test(cleaned) || cleaned.startsWith("/") || cleaned.startsWith("./") || cleaned.startsWith("../"));
879
+ if (looksLikeLocalPath) {
880
+ return `local/${cleaned.toLowerCase()}`;
881
+ }
882
+ const sshMatch = cleaned.match(/^(?:.+@)?([^:/]+)[:/](.+)$/);
883
+ if (!cleaned.includes("://") && sshMatch) {
884
+ const host = String(sshMatch[1] || "").trim().toLowerCase();
885
+ const repoPath = String(sshMatch[2] || "")
886
+ .trim()
887
+ .replace(/^\/+/, "")
888
+ .replace(/\/+$/, "")
889
+ .toLowerCase();
890
+ return host && repoPath ? `${host}/${repoPath}` : "";
891
+ }
892
+
893
+ try {
894
+ const parsed = new URL(cleaned);
895
+ const host = String(parsed.hostname || "").trim().toLowerCase();
896
+ const repoPath = String(parsed.pathname || "")
897
+ .trim()
898
+ .replace(/^\/+/, "")
899
+ .replace(/\/+$/, "")
900
+ .toLowerCase();
901
+ return host && repoPath ? `${host}/${repoPath}` : "";
902
+ } catch {
903
+ return cleaned.toLowerCase();
904
+ }
905
+ }
906
+
907
+ function uniqueNormalizedStrings(values) {
908
+ const seen = new Set();
909
+ const ordered = [];
910
+ for (const value of Array.isArray(values) ? values : []) {
911
+ const text = String(value || "").trim();
912
+ if (!text) continue;
913
+ const key = text.toLowerCase();
914
+ if (seen.has(key)) continue;
915
+ seen.add(key);
916
+ ordered.push(text);
917
+ }
918
+ return ordered;
919
+ }
920
+
921
+ function normalizeRepoBindingEntry(binding, index = 0) {
922
+ if (!binding || typeof binding !== "object") return null;
923
+
924
+ const role = String(binding.role || "").trim().toLowerCase();
925
+ const canonicalRepoName = String(binding.canonical_repo_name || "").trim();
926
+ const canonicalRemoteUrl = String(binding.canonical_remote_url || "").trim();
927
+ const clientRepoName = String(binding.client_repo_name || "").trim();
928
+ const clientRemoteUrl = String(binding.client_remote_url || "").trim();
929
+ const localRepoAliases = uniqueNormalizedStrings(binding.local_repo_aliases || []);
930
+ const baselineBranch = String(binding.baseline_branch || "").trim();
931
+
932
+ const remoteFingerprints = uniqueNormalizedStrings([
933
+ ...(Array.isArray(binding.remote_fingerprints) ? binding.remote_fingerprints : []),
934
+ normalizeRemoteFingerprint(canonicalRemoteUrl),
935
+ normalizeRemoteFingerprint(clientRemoteUrl),
936
+ ]);
937
+
938
+ const matchingNames = uniqueNormalizedStrings([
939
+ ...(Array.isArray(binding.matching_names) ? binding.matching_names : []),
940
+ canonicalRepoName,
941
+ clientRepoName,
942
+ ...localRepoAliases,
943
+ ]);
944
+
945
+ if (!role && !matchingNames.length && !remoteFingerprints.length) return null;
946
+
947
+ return {
948
+ role: role || `repo_${index + 1}`,
949
+ canonical_repo_name: canonicalRepoName || null,
950
+ canonical_remote_url: canonicalRemoteUrl || null,
951
+ client_repo_name: clientRepoName || null,
952
+ client_remote_url: clientRemoteUrl || null,
953
+ local_repo_aliases: localRepoAliases,
954
+ baseline_branch: baselineBranch || null,
955
+ matching_names: matchingNames,
956
+ remote_fingerprints: remoteFingerprints,
957
+ };
958
+ }
959
+
960
+ function normalizeRepoBindings(bindings) {
961
+ const normalized = [];
962
+ const seenRoles = new Set();
963
+ for (const [index, binding] of (Array.isArray(bindings) ? bindings : []).entries()) {
964
+ const item = normalizeRepoBindingEntry(binding, index);
965
+ if (!item) continue;
966
+ const roleKey = String(item.role || "").trim().toLowerCase();
967
+ if (seenRoles.has(roleKey)) continue;
968
+ seenRoles.add(roleKey);
969
+ normalized.push(item);
970
+ }
971
+ return normalized;
972
+ }
973
+
974
+ function getRepoRemoteFingerprints(repoPath) {
975
+ const output = runGitTry(repoPath, ["remote", "-v"]);
976
+ if (!output) return [];
977
+ const fingerprints = [];
978
+ for (const line of String(output).split(/\r?\n/)) {
979
+ const parts = line.trim().split(/\s+/);
980
+ if (parts.length < 2) continue;
981
+ const normalized = normalizeRemoteFingerprint(parts[1]);
982
+ if (normalized) fingerprints.push(normalized);
983
+ }
984
+ return uniqueNormalizedStrings(fingerprints);
985
+ }
986
+
987
+ function listGitRepoCandidates(searchRoot, currentRepoRoot) {
988
+ const candidates = [];
989
+ const seen = new Set();
990
+
991
+ function pushCandidate(absPath, rootForDir) {
992
+ const abs = path.resolve(absPath);
993
+ if (!hasGitDir(abs) || seen.has(abs)) return;
994
+ seen.add(abs);
995
+ const rootBase = rootForDir ? path.resolve(rootForDir) : path.dirname(abs);
996
+ const rel = path.relative(rootBase, abs);
997
+ const dir = rel && rel !== "" ? rel : ".";
998
+ const repoName = path.basename(abs);
999
+ candidates.push({
1000
+ abs,
1001
+ dir,
1002
+ name: repoName,
1003
+ prefix: `${repoName.replace(/\\\\/g, "/")}/`,
1004
+ remote_fingerprints: getRepoRemoteFingerprints(abs),
1005
+ is_current_repo: currentRepoRoot ? path.resolve(currentRepoRoot) === abs : false,
1006
+ });
1007
+ }
1008
+
1009
+ if (currentRepoRoot) {
1010
+ pushCandidate(currentRepoRoot, path.dirname(currentRepoRoot));
1011
+ }
1012
+
1013
+ pushCandidate(searchRoot, path.dirname(searchRoot));
1014
+
1015
+ let entries = [];
1016
+ try {
1017
+ entries = fs.readdirSync(searchRoot, { withFileTypes: true });
1018
+ } catch {
1019
+ entries = [];
1020
+ }
1021
+ for (const entry of entries) {
1022
+ if (!entry || !entry.isDirectory || !entry.isDirectory()) continue;
1023
+ const abs = path.join(searchRoot, entry.name);
1024
+ pushCandidate(abs, searchRoot);
1025
+ }
1026
+
1027
+ return candidates;
1028
+ }
1029
+
1030
+ function scoreBindingCandidate(binding, candidate) {
1031
+ const candidateName = String(candidate?.name || "").trim().toLowerCase();
1032
+ const bindingNames = new Set((binding?.matching_names || []).map((name) => String(name || "").trim().toLowerCase()).filter(Boolean));
1033
+ const bindingRemotes = new Set((binding?.remote_fingerprints || []).map((value) => String(value || "").trim().toLowerCase()).filter(Boolean));
1034
+ const candidateRemotes = new Set((candidate?.remote_fingerprints || []).map((value) => String(value || "").trim().toLowerCase()).filter(Boolean));
1035
+ const canonicalRemoteFingerprint = normalizeRemoteFingerprint(binding?.canonical_remote_url).toLowerCase();
1036
+ const clientRemoteFingerprint = normalizeRemoteFingerprint(binding?.client_remote_url).toLowerCase();
1037
+
1038
+ let score = 0;
1039
+ const matchedBy = [];
1040
+
1041
+ if (binding.canonical_repo_name && candidateName === String(binding.canonical_repo_name).trim().toLowerCase()) {
1042
+ score = Math.max(score, 240);
1043
+ matchedBy.push("canonical_name");
1044
+ }
1045
+ if (binding.client_repo_name && candidateName === String(binding.client_repo_name).trim().toLowerCase()) {
1046
+ score = Math.max(score, 260);
1047
+ matchedBy.push("client_name");
1048
+ }
1049
+ if (bindingNames.has(candidateName)) {
1050
+ score = Math.max(score, 200);
1051
+ matchedBy.push("alias");
1052
+ }
1053
+ for (const remote of candidateRemotes) {
1054
+ if (clientRemoteFingerprint && remote === clientRemoteFingerprint) {
1055
+ score = Math.max(score, 340);
1056
+ matchedBy.push("client_remote");
1057
+ break;
1058
+ }
1059
+ if (canonicalRemoteFingerprint && remote === canonicalRemoteFingerprint) {
1060
+ score = Math.max(score, 300);
1061
+ matchedBy.push("canonical_remote");
1062
+ break;
1063
+ }
1064
+ if (bindingRemotes.has(remote)) {
1065
+ score = Math.max(score, 320);
1066
+ matchedBy.push("remote");
1067
+ break;
1068
+ }
1069
+ }
1070
+
1071
+ return { score, matchedBy: uniqueNormalizedStrings(matchedBy) };
1072
+ }
1073
+
1074
+ function resolveConfiguredRepoBindings(bindings) {
1075
+ const normalizedBindings = normalizeRepoBindings(bindings);
1076
+ if (!normalizedBindings.length) {
1077
+ return { mode: "none", root: null, repos: [], missing: [], bindings: [] };
1078
+ }
1079
+
1080
+ const cwd = process.cwd();
1081
+ const currentRepoRoot = findGitTopLevel(cwd);
1082
+ const scanStart = currentRepoRoot ? path.dirname(currentRepoRoot) : cwd;
1083
+
1084
+ const ancestors = [];
1085
+ let cur = scanStart;
1086
+ for (let i = 0; i < 8; i += 1) {
1087
+ ancestors.push(cur);
1088
+ const parent = path.dirname(cur);
1089
+ if (parent === cur) break;
1090
+ cur = parent;
1091
+ }
1092
+
1093
+ let fallbackCandidates = currentRepoRoot ? listGitRepoCandidates(path.dirname(currentRepoRoot), currentRepoRoot) : [];
1094
+ let fallbackRoot = currentRepoRoot ? path.dirname(currentRepoRoot) : null;
1095
+
1096
+ for (const candidateRoot of ancestors) {
1097
+ const candidates = listGitRepoCandidates(candidateRoot, currentRepoRoot);
1098
+ if (!fallbackCandidates.length && candidates.length) {
1099
+ fallbackCandidates = candidates;
1100
+ fallbackRoot = candidateRoot;
1101
+ }
1102
+
1103
+ const assigned = new Set();
1104
+ const found = [];
1105
+ for (const binding of normalizedBindings) {
1106
+ let best = null;
1107
+ for (const candidate of candidates) {
1108
+ if (assigned.has(candidate.abs)) continue;
1109
+ const match = scoreBindingCandidate(binding, candidate);
1110
+ if (!match.score) continue;
1111
+ if (!best || match.score > best.match.score) {
1112
+ best = { candidate, match };
1113
+ }
1114
+ }
1115
+ if (!best) continue;
1116
+ assigned.add(best.candidate.abs);
1117
+ found.push({
1118
+ name: best.candidate.name,
1119
+ dir: best.candidate.dir,
1120
+ abs: best.candidate.abs,
1121
+ prefix: best.candidate.prefix,
1122
+ role: binding.role,
1123
+ canonical_repo_name: binding.canonical_repo_name,
1124
+ canonical_remote_url: binding.canonical_remote_url,
1125
+ client_repo_name: binding.client_repo_name,
1126
+ client_remote_url: binding.client_remote_url,
1127
+ baseline_branch: binding.baseline_branch || null,
1128
+ matched_by: best.match.matchedBy,
1129
+ remote_fingerprints: best.candidate.remote_fingerprints,
1130
+ });
1131
+ }
1132
+
1133
+ if (found.length) {
1134
+ const foundRoles = new Set(found.map((repo) => repo.role));
1135
+ const missing = normalizedBindings
1136
+ .filter((binding) => !foundRoles.has(binding.role))
1137
+ .map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role);
1138
+ return {
1139
+ mode: currentRepoRoot && found.length === 1 && found[0].abs === currentRepoRoot ? "repo" : "workspace",
1140
+ root: candidateRoot,
1141
+ repos: found,
1142
+ missing,
1143
+ bindings: normalizedBindings,
1144
+ };
1145
+ }
1146
+ }
1147
+
1148
+ return {
1149
+ mode: currentRepoRoot ? "repo" : "none",
1150
+ root: fallbackRoot,
1151
+ repos: [],
1152
+ missing: normalizedBindings.map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role),
1153
+ bindings: normalizedBindings,
1154
+ };
1155
+ }
1156
+
1157
+ function fetchBaseBranches(repoPath, preferredBranch) {
1158
+ const preferred = String(preferredBranch || "").trim();
1159
+ if (preferred && runGitOk(repoPath, ["fetch", "origin", preferred, "--prune", "--quiet"])) return true;
837
1160
  if (runGitOk(repoPath, ["fetch", "origin", "main", "master", "--prune", "--quiet"])) return true;
838
1161
  return runGitOk(repoPath, ["fetch", "--all", "--prune", "--quiet"]);
839
1162
  }
840
1163
 
841
- function resolveBaseRef(repoPath) {
1164
+ function buildExternalBaseRefName(remoteUrl, branch) {
1165
+ const branchSlug = String(branch || "main")
1166
+ .trim()
1167
+ .toLowerCase()
1168
+ .replace(/[^a-z0-9._-]+/g, "-")
1169
+ .replace(/^-+|-+$/g, "") || "main";
1170
+ const digest = createHash("sha1")
1171
+ .update(`${String(remoteUrl || "").trim()}::${branchSlug}`)
1172
+ .digest("hex")
1173
+ .slice(0, 12);
1174
+ return `refs/myte/bases/${branchSlug}-${digest}`;
1175
+ }
1176
+
1177
+ function fetchExternalBaseRef(repoPath, remoteUrl, preferredBranch) {
1178
+ const remote = String(remoteUrl || "").trim();
1179
+ if (!remote) return null;
1180
+
1181
+ const candidateBranches = uniqueNormalizedStrings([preferredBranch, "main", "master"]);
1182
+ for (const branch of candidateBranches) {
1183
+ const refName = buildExternalBaseRefName(remote, branch);
1184
+ if (runGitOk(repoPath, ["fetch", "--quiet", "--no-tags", remote, `${branch}:${refName}`])) {
1185
+ return { ref: refName, branch };
1186
+ }
1187
+ }
1188
+ return null;
1189
+ }
1190
+
1191
+ function resolveExternalCompareRemoteUrl(repo) {
1192
+ const canonicalRemoteUrl = String(repo?.canonical_remote_url || "").trim();
1193
+ const clientRemoteUrl = String(repo?.client_remote_url || "").trim();
1194
+ if (!canonicalRemoteUrl || !clientRemoteUrl) return null;
1195
+
1196
+ const canonicalFingerprint = normalizeRemoteFingerprint(canonicalRemoteUrl);
1197
+ const clientFingerprint = normalizeRemoteFingerprint(clientRemoteUrl);
1198
+ if (!canonicalFingerprint || !clientFingerprint || canonicalFingerprint === clientFingerprint) return null;
1199
+
1200
+ const candidateFingerprints = new Set(
1201
+ (Array.isArray(repo?.remote_fingerprints) ? repo.remote_fingerprints : [])
1202
+ .map((value) => String(value || "").trim().toLowerCase())
1203
+ .filter(Boolean)
1204
+ );
1205
+ if (!candidateFingerprints.has(clientFingerprint.toLowerCase())) return null;
1206
+ if (candidateFingerprints.has(canonicalFingerprint.toLowerCase())) return null;
1207
+
1208
+ return canonicalRemoteUrl;
1209
+ }
1210
+
1211
+ function resolveBaseRef(repoPath, preferredBranch) {
1212
+ const preferred = String(preferredBranch || "").trim();
1213
+ if (preferred) {
1214
+ if (runGitOk(repoPath, ["rev-parse", "--verify", `refs/remotes/origin/${preferred}`])) return `origin/${preferred}`;
1215
+ if (runGitOk(repoPath, ["rev-parse", "--verify", `refs/heads/${preferred}`])) return preferred;
1216
+ }
842
1217
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/remotes/origin/main"])) return "origin/main";
843
1218
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/remotes/origin/master"])) return "origin/master";
844
1219
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/heads/main"])) return "main";
@@ -849,13 +1224,24 @@ function resolveBaseRef(repoPath) {
849
1224
  function collectGitDiffWithDiagnostics({
850
1225
  projectId,
851
1226
  repoNames,
1227
+ repoBindings,
852
1228
  maxChars,
853
1229
  fetchRemote = true,
854
1230
  } = {}) {
855
1231
  const configuredRepos = Array.isArray(repoNames) ? repoNames.map(String).map((s) => s.trim()).filter(Boolean) : [];
1232
+ const configuredBindings = normalizeRepoBindings(repoBindings);
856
1233
  const diagnostics = {
857
1234
  project_id: projectId || null,
858
1235
  requested_repo_names: configuredRepos,
1236
+ requested_repo_bindings: configuredBindings.map((binding) => ({
1237
+ role: binding.role,
1238
+ canonical_repo_name: binding.canonical_repo_name,
1239
+ canonical_remote_url: binding.canonical_remote_url,
1240
+ client_repo_name: binding.client_repo_name,
1241
+ client_remote_url: binding.client_remote_url,
1242
+ local_repo_aliases: binding.local_repo_aliases,
1243
+ baseline_branch: binding.baseline_branch,
1244
+ })),
859
1245
  fetch_remote: Boolean(fetchRemote),
860
1246
  mode: "none",
861
1247
  search_root: null,
@@ -870,7 +1256,9 @@ function collectGitDiffWithDiagnostics({
870
1256
  };
871
1257
 
872
1258
  try {
873
- const resolved = resolveConfiguredRepos(configuredRepos);
1259
+ const resolved = configuredBindings.length
1260
+ ? resolveConfiguredRepoBindings(configuredBindings)
1261
+ : resolveConfiguredRepos(configuredRepos);
874
1262
  diagnostics.mode = resolved.mode || "none";
875
1263
  diagnostics.search_root = resolved.root || null;
876
1264
  diagnostics.found_repos = (resolved.repos || []).map((r) => r.name);
@@ -888,7 +1276,15 @@ function collectGitDiffWithDiagnostics({
888
1276
  const sections = [];
889
1277
  if (projectId) sections.push(`# Project: ${projectId}`);
890
1278
  sections.push(`# Mode: ${resolved.mode}`);
891
- sections.push(`# Configured repos: ${configuredRepos.join(", ") || ""}`);
1279
+ if (configuredBindings.length) {
1280
+ sections.push(
1281
+ `# Configured repo bindings: ${configuredBindings
1282
+ .map((binding) => `${binding.role}=${binding.client_repo_name || binding.canonical_repo_name || binding.role}`)
1283
+ .join(", ")}`
1284
+ );
1285
+ } else {
1286
+ sections.push(`# Configured repos: ${configuredRepos.join(", ") || ""}`);
1287
+ }
892
1288
  if (resolved.missing && resolved.missing.length) {
893
1289
  sections.push(`# Missing locally (skipped): ${resolved.missing.join(", ")}`);
894
1290
  }
@@ -900,8 +1296,12 @@ function collectGitDiffWithDiagnostics({
900
1296
  dir: repo.dir || ".",
901
1297
  root: repo.abs,
902
1298
  status: "ok",
1299
+ role: repo.role || null,
903
1300
  head_branch: null,
904
1301
  base_ref: null,
1302
+ base_ref_label: null,
1303
+ compare_mode: "local_origin",
1304
+ compare_remote_url: null,
905
1305
  has_changes: false,
906
1306
  changed_blocks: {
907
1307
  base_vs_head: false,
@@ -914,22 +1314,55 @@ function collectGitDiffWithDiagnostics({
914
1314
  };
915
1315
 
916
1316
  const { name, dir, abs, prefix } = repo;
917
- const fetchDiag = { attempted: false, ok: false, message: "" };
1317
+ const externalCompareRemoteUrl = resolveExternalCompareRemoteUrl(repo);
1318
+ const fetchDiag = {
1319
+ attempted: false,
1320
+ ok: false,
1321
+ message: "",
1322
+ compare_mode: externalCompareRemoteUrl ? "external_remote" : "local_origin",
1323
+ compare_remote_url: externalCompareRemoteUrl || null,
1324
+ };
1325
+ let baseRef = null;
1326
+ let baseRefLabel = null;
918
1327
  if (fetchRemote) {
919
1328
  fetchDiag.attempted = true;
920
- fetchDiag.ok = fetchBaseBranches(abs);
921
- if (!fetchDiag.ok) {
922
- fetchDiag.message = "failed to refresh origin main/master";
923
- repoSummary.errors.push(fetchDiag.message);
924
- diagnostics.warnings.push(`Repo "${name}": ${fetchDiag.message}`);
1329
+ if (externalCompareRemoteUrl) {
1330
+ const externalBase = fetchExternalBaseRef(abs, externalCompareRemoteUrl, repo.baseline_branch);
1331
+ fetchDiag.ok = Boolean(externalBase?.ref);
1332
+ if (externalBase?.ref) {
1333
+ baseRef = externalBase.ref;
1334
+ baseRefLabel = `${externalBase.branch}@canonical`;
1335
+ } else {
1336
+ fetchDiag.message = `failed to refresh canonical compare branch from ${externalCompareRemoteUrl}`;
1337
+ repoSummary.errors.push(fetchDiag.message);
1338
+ diagnostics.warnings.push(`Repo "${name}": ${fetchDiag.message}`);
1339
+ }
1340
+ } else {
1341
+ fetchDiag.ok = fetchBaseBranches(abs, repo.baseline_branch);
1342
+ if (!fetchDiag.ok) {
1343
+ fetchDiag.message = "failed to refresh origin main/master";
1344
+ repoSummary.errors.push(fetchDiag.message);
1345
+ diagnostics.warnings.push(`Repo "${name}": ${fetchDiag.message}`);
1346
+ }
925
1347
  }
926
1348
  }
927
1349
 
928
1350
  const headBranch = runGitTry(abs, ["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
929
- const baseRef = resolveBaseRef(abs);
1351
+ if (!baseRef && !externalCompareRemoteUrl) {
1352
+ baseRef = resolveBaseRef(abs, repo.baseline_branch);
1353
+ }
930
1354
  repoSummary.head_branch = headBranch;
931
1355
  repoSummary.base_ref = baseRef || "base-unresolved";
1356
+ repoSummary.base_ref_label = baseRefLabel || repoSummary.base_ref;
932
1357
  repoSummary.fetch = fetchDiag;
1358
+ repoSummary.compare_mode = fetchDiag.compare_mode;
1359
+ repoSummary.compare_remote_url = fetchDiag.compare_remote_url;
1360
+ if (repo.matched_by) repoSummary.matched_by = repo.matched_by;
1361
+ if (repo.baseline_branch) repoSummary.baseline_branch = repo.baseline_branch;
1362
+ if (repo.canonical_repo_name) repoSummary.canonical_repo_name = repo.canonical_repo_name;
1363
+ if (repo.canonical_remote_url) repoSummary.canonical_remote_url = repo.canonical_remote_url;
1364
+ if (repo.client_repo_name) repoSummary.client_repo_name = repo.client_repo_name;
1365
+ if (repo.client_remote_url) repoSummary.client_remote_url = repo.client_remote_url;
933
1366
 
934
1367
  let baseDiff = "";
935
1368
  if (baseRef) {
@@ -999,8 +1432,16 @@ function collectGitDiffWithDiagnostics({
999
1432
  repoSummary.status = "partial";
1000
1433
  }
1001
1434
 
1002
- let section = `### ${name} (${dir || "."})\n\n`;
1003
- section += `# ?? Base vs HEAD (${repoSummary.base_ref}...HEAD | head=${headBranch})\n`;
1435
+ const headingParts = [`${name} (${dir || "."})`];
1436
+ if (repo.role) headingParts.push(`role=${repo.role}`);
1437
+ let section = `### ${headingParts.join(" | ")}\n\n`;
1438
+ section += `# ?? Base vs HEAD (${repoSummary.base_ref_label}...HEAD | head=${headBranch})\n`;
1439
+ if (repoSummary.matched_by?.length) {
1440
+ section += `# ?? Matched by ${repoSummary.matched_by.join(", ")}\n`;
1441
+ }
1442
+ if (repoSummary.compare_remote_url) {
1443
+ section += `# ?? Compare remote ${repoSummary.compare_remote_url}\n`;
1444
+ }
1004
1445
  if (baseDiff) section += `${baseDiff}\n\n`;
1005
1446
  if (staged) section += `# ?? Staged changes\n${staged}\n\n`;
1006
1447
  if (unstaged) section += `# ?? Unstaged changes\n${unstaged}\n\n`;
@@ -1469,7 +1910,7 @@ async function runRunQaqc(args) {
1469
1910
 
1470
1911
  let resolved;
1471
1912
  try {
1472
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
1913
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
1473
1914
  } catch (err) {
1474
1915
  console.error(err?.message || err);
1475
1916
  process.exit(1);
@@ -2063,6 +2504,20 @@ function resolveBootstrapWorkspace(repoNames) {
2063
2504
  return resolved;
2064
2505
  }
2065
2506
 
2507
+ function resolvePortableWorkspace(repoNames) {
2508
+ const configured = Array.isArray(repoNames) ? repoNames.map(String).map((s) => s.trim()).filter(Boolean) : [];
2509
+ const resolved = resolveConfiguredRepos(configured);
2510
+ if (resolved.root) {
2511
+ return resolved;
2512
+ }
2513
+ return {
2514
+ mode: "cwd",
2515
+ root: process.cwd(),
2516
+ repos: [],
2517
+ missing: configured,
2518
+ };
2519
+ }
2520
+
2066
2521
  function resolveCommandCenterRoots(wrapperRoot, outputDir) {
2067
2522
  const targetRoot = outputDir
2068
2523
  ? path.resolve(process.cwd(), String(outputDir))
@@ -2545,7 +3000,7 @@ async function resolveSuggestionsOutputContext({ args, key, apiBase, timeoutMs,
2545
3000
  const repoNames = Array.isArray(snapshot?.repo_names) && snapshot.repo_names.length
2546
3001
  ? snapshot.repo_names
2547
3002
  : (await fetchProjectConfig({ apiBase, key, timeoutMs })).repo_names || [];
2548
- const resolved = resolveBootstrapWorkspace(repoNames);
3003
+ const resolved = resolvePortableWorkspace(repoNames);
2549
3004
  return {
2550
3005
  wrapperRoot: resolved.root,
2551
3006
  targetRoot: path.join(resolved.root, "MyteCommandCenter"),
@@ -3155,11 +3610,15 @@ async function runConfig(args) {
3155
3610
  }
3156
3611
 
3157
3612
  const repoNames = Array.isArray(cfg.repo_names) ? cfg.repo_names : [];
3158
- const resolved = resolveConfiguredRepos(repoNames);
3613
+ const repoBindings = Array.isArray(cfg.repo_bindings) ? cfg.repo_bindings : [];
3614
+ const resolved = repoBindings.length
3615
+ ? resolveConfiguredRepoBindings(repoBindings)
3616
+ : resolveConfiguredRepos(repoNames);
3159
3617
  const payload = {
3160
3618
  api_base: apiBase,
3161
3619
  project_id: cfg.project_id,
3162
3620
  repo_names: repoNames,
3621
+ repo_bindings: repoBindings,
3163
3622
  local: {
3164
3623
  mode: resolved.mode,
3165
3624
  root: resolved.root,
@@ -3174,6 +3633,13 @@ async function runConfig(args) {
3174
3633
  console.log(`Project: ${payload.project_id || "(unknown)"}`);
3175
3634
  console.log(`API base: ${payload.api_base}`);
3176
3635
  console.log(`Configured repos: ${repoNames.join(", ") || "(none)"}`);
3636
+ if (repoBindings.length) {
3637
+ console.log(
3638
+ `Repo bindings: ${repoBindings
3639
+ .map((binding) => `${binding.role}=${binding.client_repo_name || binding.canonical_repo_name || binding.role}`)
3640
+ .join(", ")}`
3641
+ );
3642
+ }
3177
3643
  console.log(`Local mode: ${payload.local.mode}`);
3178
3644
  if (payload.local.found.length) console.log(`Found locally: ${payload.local.found.join(", ")}`);
3179
3645
  if (payload.local.missing.length) console.log(`Missing locally: ${payload.local.missing.join(", ")}`);
@@ -3209,7 +3675,7 @@ async function runBootstrap(args) {
3209
3675
 
3210
3676
  let resolved;
3211
3677
  try {
3212
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
3678
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3213
3679
  } catch (err) {
3214
3680
  console.error(err?.message || err);
3215
3681
  process.exit(1);
@@ -3303,7 +3769,7 @@ async function runSyncQaqc(args) {
3303
3769
 
3304
3770
  let resolved;
3305
3771
  try {
3306
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
3772
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3307
3773
  } catch (err) {
3308
3774
  console.error(err?.message || err);
3309
3775
  process.exit(1);
@@ -3397,7 +3863,7 @@ async function runFeedbackSync(args) {
3397
3863
 
3398
3864
  let resolved;
3399
3865
  try {
3400
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
3866
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3401
3867
  } catch (err) {
3402
3868
  console.error(err?.message || err);
3403
3869
  process.exit(1);
@@ -4037,30 +4503,41 @@ async function runQuery(args) {
4037
4503
  try {
4038
4504
  cfg = await fetchProjectConfig({ apiBase, key, timeoutMs });
4039
4505
  } catch (err) {
4040
- console.warn("Warning: project config unavailable for --with-diff. Continuing without diff context.");
4041
- console.warn(`Detail: ${err?.message || err}`);
4042
- cfg = null;
4506
+ console.error("Failed to fetch project config for --with-diff:", err?.message || err);
4507
+ process.exit(1);
4043
4508
  }
4044
4509
 
4045
- if (cfg) {
4046
- const repoNames = Array.isArray(cfg.repo_names) ? cfg.repo_names : [];
4047
- const diffResult = collectGitDiffWithDiagnostics({
4048
- projectId: cfg.project_id,
4049
- repoNames,
4050
- maxChars: diffLimit,
4051
- fetchRemote,
4052
- });
4053
- diffText = diffResult.text;
4054
- diffDiagnostics = diffResult.diagnostics;
4055
- if (diffDiagnostics?.errors?.length) {
4056
- for (const warning of diffDiagnostics.errors) console.warn(`Warning: ${warning}`);
4057
- }
4058
- } else {
4059
- diffText = "";
4060
- diffDiagnostics = null;
4510
+ const repoNames = Array.isArray(cfg.repo_names) ? cfg.repo_names : [];
4511
+ const repoBindings = Array.isArray(cfg.repo_bindings) ? cfg.repo_bindings : [];
4512
+ if (!repoBindings.length && !repoNames.length) {
4513
+ console.error("No project repositories are configured for --with-diff.");
4514
+ console.error("Ask the project owner or builder to configure the project repos in Myte first.");
4515
+ process.exit(1);
4516
+ }
4517
+
4518
+ const diffResult = collectGitDiffWithDiagnostics({
4519
+ projectId: cfg.project_id,
4520
+ repoNames,
4521
+ repoBindings,
4522
+ maxChars: diffLimit,
4523
+ fetchRemote,
4524
+ });
4525
+ diffText = diffResult.text;
4526
+ diffDiagnostics = diffResult.diagnostics;
4527
+ if (diffDiagnostics?.errors?.length) {
4528
+ for (const warning of diffDiagnostics.errors) console.warn(`Warning: ${warning}`);
4061
4529
  }
4062
- if (!diffText) {
4063
- console.error("Warning: no diff context collected. Continuing without --with-diff context.");
4530
+ if (!Array.isArray(diffDiagnostics?.found_repos) || !diffDiagnostics.found_repos.length) {
4531
+ console.error("No configured project repos were found locally for --with-diff.");
4532
+ const expectedBindings = Array.isArray(diffDiagnostics?.requested_repo_bindings)
4533
+ ? diffDiagnostics.requested_repo_bindings
4534
+ .map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role)
4535
+ .filter(Boolean)
4536
+ : [];
4537
+ const expected = expectedBindings.length ? expectedBindings : repoNames;
4538
+ console.error(`Expected repo mapping or folder match for: ${expected.join(", ")}`);
4539
+ console.error("Run `myte config --json` to inspect the configured repos and local resolution.");
4540
+ process.exit(1);
4064
4541
  }
4065
4542
  }
4066
4543
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mytegroupinc/myte-core",
3
- "version": "0.0.19",
4
- "description": "Myte CLI core implementation (Project Assistant + Myte AI gateway).",
3
+ "version": "0.0.20",
4
+ "description": "Myte CLI core implementation.",
5
5
  "type": "commonjs",
6
6
  "main": "cli.js",
7
7
  "files": [