@mytegroupinc/myte-core 0.0.19 → 0.0.21

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 +15 -142
  2. package/cli.js +1043 -49
  3. package/package.json +2 -2
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");
@@ -98,6 +98,7 @@ function splitCommand(argv) {
98
98
  "update-owner",
99
99
  "update-client",
100
100
  "feedback-sync",
101
+ "feedback",
101
102
  "help",
102
103
  "--help",
103
104
  "-h",
@@ -141,6 +142,17 @@ function parseArgs(argv) {
141
142
  "target-contact-ids",
142
143
  "status",
143
144
  "source",
145
+ "feedback-id",
146
+ "reason",
147
+ "review-action",
148
+ "idempotency-key",
149
+ "user-id",
150
+ "assigned-user-id",
151
+ "due-date",
152
+ "priority",
153
+ "review-note",
154
+ "tags",
155
+ "tag",
144
156
  "mission-ids",
145
157
  "client-session-id",
146
158
  ],
@@ -194,7 +206,7 @@ function parseArgs(argv) {
194
206
 
195
207
  function printHelp() {
196
208
  const text = [
197
- "myte - Myte Project Assistant CLI",
209
+ "myte - Myte CLI",
198
210
  "",
199
211
  "Usage:",
200
212
  " myte query \"<text>\" [--with-diff] [--context \"...\"]",
@@ -212,6 +224,10 @@ function printHelp() {
212
224
  " myte update-owner --subject \"<text>\" [--body-markdown \"...\"] [--body-file ./update.md] [--json]",
213
225
  " myte update-client --subject \"<text>\" [--body-markdown \"...\"] [--body-file ./update.md] [--target-contact-ids <id1,id2>] [--json]",
214
226
  " myte feedback-sync [--status <value>] [--source <value>] [--with-prd-text|--no-with-prd-text] [--output-dir ./MyteCommandCenter] [--json]",
227
+ " myte feedback status --feedback-id <id> --status todo|in_progress|in_review|completed|deployed|rejected|archived --reason \"...\"",
228
+ " myte feedback edit --feedback-id <id> [--title \"...\"] [--feedback-text \"...\"] [--priority High] [--reason \"...\"]",
229
+ " myte feedback validate --file ./MyteCommandCenter/reviews/feedback/<id>-status.yml [--json]",
230
+ " myte feedback apply --file ./MyteCommandCenter/reviews/feedback/<id>-status.yml [--json]",
215
231
  " myte create-prd <file.md> [more.md ...] [--json] [--title \"...\"] [--description \"...\"]",
216
232
  " cat file.md | myte create-prd --stdin [--title \"...\"] [--description \"...\"]",
217
233
  " cat update.md | myte update-owner --stdin --subject \"Owner update\"",
@@ -232,13 +248,13 @@ function printHelp() {
232
248
  " - Set MYTEAI_API_KEY in a workspace .env (or env var) for `myte ai`",
233
249
  "",
234
250
  "bootstrap contract:",
235
- " - Run from the wrapper root that contains the project's configured repo folders",
251
+ " - Run from any workspace where you want local MyteCommandCenter data written",
236
252
  " - Writes MyteCommandCenter/data/project.yml plus phases, epics, stories, and missions locally",
237
253
  " - Uses the project-scoped bootstrap snapshot from the Myte API",
238
254
  " - Mission cards include richer execution context like complexity, estimated_hours, due_date, subtasks, technical_requirements, resources_needed, labels, and normalized test_cases",
239
255
  "",
240
256
  "sync-qaqc contract:",
241
- " - Run from the wrapper root that contains the project's configured repo folders",
257
+ " - Run from any workspace where you want local MyteCommandCenter data written",
242
258
  " - Works even if bootstrap has not been run yet; it creates MyteCommandCenter/data/qaqc.yml",
243
259
  " - Writes the active mission QAQC working set into one deterministic file: MyteCommandCenter/data/qaqc.yml",
244
260
  "",
@@ -285,13 +301,19 @@ function printHelp() {
285
301
  " - If the project has no linked client contacts, the backend falls back to the project owner for internal projects",
286
302
  "",
287
303
  "feedback-sync contract:",
288
- " - Runs from the wrapper root that contains the project's configured repo folders",
304
+ " - Runs from any workspace where you want local MyteCommandCenter data written",
289
305
  " - Syncs pending feedback by default so local Command Center data stays focused on active work",
290
306
  " - Writes project feedback metadata and conversation turns into MyteCommandCenter/data/feedback.yml",
291
307
  " - Stores full PRD context in MyteCommandCenter/PRD/feedback-sync/*.md and points to those files from feedback.yml",
292
308
  "",
309
+ "feedback review contract:",
310
+ " - Draft commands write review artifacts under MyteCommandCenter/reviews/feedback/*.yml for local IDE diff review",
311
+ " - validate/apply send those artifacts to /api/project-assistant/feedback/<id>/refinement/*",
312
+ " - The backend owns authorization, stale snapshot checks, allowed field/transition rules, and history",
313
+ " - apply is idempotent and does not rewrite local feedback.yml; run feedback-sync after apply to refresh local state",
314
+ "",
293
315
  "Options:",
294
- " --with-diff Include deterministic git diffs (project-scoped)",
316
+ " --with-diff Include deterministic git diffs (project-scoped; fails fast if no project repos are configured or resolved)",
295
317
  " --diff-limit <chars> Truncate diff context to N chars (default: 200000)",
296
318
  " --timeout-ms <ms> Request timeout (default: 300000)",
297
319
  " --base-url <url> API base (default: https://api.myte.dev)",
@@ -299,7 +321,7 @@ function printHelp() {
299
321
  " --json-response Ask the Myte AI gateway to return clean JSON only and send OpenAI-compatible response_format",
300
322
  " --max-output-tokens Output token cap for `myte ai` simple queries",
301
323
  " --temperature <num> Temperature for `myte ai` simple queries",
302
- " --output-dir <path> Command Center output directory (default: <wrapper-root>/MyteCommandCenter)",
324
+ " --output-dir <path> Command Center output directory (default: <current-workspace>/MyteCommandCenter)",
303
325
  " --file <path> YAML/JSON payload file for suggestions create/revise/review",
304
326
  " --stdin Read supported command content from stdin instead of inline text or a file path",
305
327
  " --title <text> Override PRD title for raw markdown uploads",
@@ -310,8 +332,15 @@ function printHelp() {
310
332
  " --body-file <path> Read update-owner or update-client markdown body from a file",
311
333
  " --target-contact-id Add one client contact ObjectId (repeatable)",
312
334
  " --target-contact-ids Comma-separated client contact ObjectIds",
313
- " --status <value> For mission status: required target status (todo|in_progress|done). For feedback-sync: optional filter (default: Pending).",
335
+ " --feedback-id <id> Feedback ObjectId for feedback review commands",
336
+ " --reason <text> Human review reason included in feedback refinement artifacts",
337
+ " --status <value> Mission status target, feedback-sync filter, or feedback review state depending on command",
314
338
  " --source <value> Feedback source filter for feedback-sync",
339
+ " --priority <value> Feedback review priority target: Low, Medium, or High",
340
+ " --user-id <id> Feedback assignee user id for `myte feedback assign`",
341
+ " --assigned-user-id Alias for --user-id in feedback assign",
342
+ " --due-date <date> Feedback due date target for edit artifacts",
343
+ " --review-note <text> Review note stored in feedback refinement history",
315
344
  " --with-prd-text Include extracted PRD text so local PRD files are materialized during feedback-sync (default: on)",
316
345
  " --no-with-prd-text Skip PRD text download and write only feedback metadata/comment turns",
317
346
  " --mission-ids <ids> Comma-separated mission business ids for run-qaqc or mission status (quote multi-id values on PowerShell)",
@@ -340,6 +369,9 @@ function printHelp() {
340
369
  " myte update-owner --subject \"QAQC progress\" --body-file ./updates/owner.md",
341
370
  " myte update-client --subject \"Weekly client update\" --body-file ./updates/week-12.md",
342
371
  " myte feedback-sync --json",
372
+ " myte feedback status --feedback-id 507f1f77bcf86cd799439011 --status in_review --reason \"Ready for owner review\"",
373
+ " myte feedback validate --file ./MyteCommandCenter/reviews/feedback/507f1f77bcf86cd799439011-status.yml --json",
374
+ " myte feedback apply --file ./MyteCommandCenter/reviews/feedback/507f1f77bcf86cd799439011-status.yml --json",
343
375
  " myte suggestions create --file ./suggestions/create.yml",
344
376
  " myte suggestions revise",
345
377
  " myte suggestions review",
@@ -589,18 +621,25 @@ function summarizeDiffDiagnosticsForContext(diagnostics) {
589
621
  project_id: diagnostics.project_id || null,
590
622
  mode: diagnostics.mode,
591
623
  requested_repos: diagnostics.requested_repo_names || [],
624
+ requested_repo_bindings: diagnostics.requested_repo_bindings || [],
592
625
  found_repos: diagnostics.found_repos || [],
593
626
  missing_repos: diagnostics.missing_repos || [],
594
627
  collected_any: Boolean(diagnostics.collected_any),
595
628
  truncation: diagnostics.truncated ? "truncated" : "full",
596
629
  repos: repos.map((repo) => ({
597
630
  name: repo.name,
631
+ role: repo.role || null,
598
632
  status: repo.status || "ok",
599
633
  head_branch: repo.head_branch || null,
600
634
  base_ref: repo.base_ref || null,
635
+ base_ref_label: repo.base_ref_label || repo.base_ref || null,
636
+ baseline_branch: repo.baseline_branch || null,
637
+ compare_mode: repo.compare_mode || "local_origin",
638
+ compare_remote_url: repo.compare_remote_url || null,
601
639
  has_changes: Boolean(repo.has_changes),
602
640
  changed_blocks: repo.changed_blocks || {},
603
641
  untracked_file_count: repo.untracked_file_count || 0,
642
+ matched_by: repo.matched_by || [],
604
643
  error_count: Array.isArray(repo.errors) ? repo.errors.length : 0,
605
644
  })),
606
645
  warnings: diagnostics.warnings || [],
@@ -642,8 +681,29 @@ const IGNORED_PATTERNS = [
642
681
  /^out\//,
643
682
  ];
644
683
 
684
+ const DIFF_MARKDOWN_ALLOWLIST = new Set([
685
+ "README.md",
686
+ "AGENTS.md",
687
+ "CodexContext.MD",
688
+ "CLAUDE.md",
689
+ "GEMINI.md",
690
+ ]);
691
+
692
+ function normalizeRepoPathForDiff(p) {
693
+ return String(p || "").replace(/\\/g, "/");
694
+ }
695
+
696
+ function shouldIgnoreMarkdownPath(p) {
697
+ const normalized = normalizeRepoPathForDiff(p);
698
+ if (!/\.md$/i.test(normalized)) return false;
699
+ const base = path.posix.basename(normalized);
700
+ return !DIFF_MARKDOWN_ALLOWLIST.has(base);
701
+ }
702
+
645
703
  function shouldIgnore(p) {
646
- return IGNORED_PATTERNS.some((re) => re.test(p));
704
+ const normalized = normalizeRepoPathForDiff(p);
705
+ if (shouldIgnoreMarkdownPath(normalized)) return true;
706
+ return IGNORED_PATTERNS.some((re) => re.test(normalized));
647
707
  }
648
708
 
649
709
  function hasGitDir(repoPath) {
@@ -833,12 +893,373 @@ function resolveConfiguredRepos(repoNames) {
833
893
  return { mode: "none", root: null, repos: [], missing: names };
834
894
  }
835
895
 
836
- function fetchBaseBranches(repoPath) {
896
+ function findGitTopLevel(startPath) {
897
+ const result = runGitRaw(startPath, ["rev-parse", "--show-toplevel"]);
898
+ if (!result.ok) return null;
899
+ const value = String(result.stdout || "").trim();
900
+ return value || null;
901
+ }
902
+
903
+ function normalizeRemoteFingerprint(value) {
904
+ const raw = String(value || "").trim();
905
+ if (!raw) return "";
906
+
907
+ const cleaned = raw.replace(/\\/g, "/").replace(/\.git$/i, "");
908
+ const looksLikeLocalPath =
909
+ !cleaned.includes("://") &&
910
+ (/^[a-zA-Z]:\//.test(cleaned) || cleaned.startsWith("/") || cleaned.startsWith("./") || cleaned.startsWith("../"));
911
+ if (looksLikeLocalPath) {
912
+ return `local/${cleaned.toLowerCase()}`;
913
+ }
914
+ const sshMatch = cleaned.match(/^(?:.+@)?([^:/]+)[:/](.+)$/);
915
+ if (!cleaned.includes("://") && sshMatch) {
916
+ const host = String(sshMatch[1] || "").trim().toLowerCase();
917
+ const repoPath = String(sshMatch[2] || "")
918
+ .trim()
919
+ .replace(/^\/+/, "")
920
+ .replace(/\/+$/, "")
921
+ .toLowerCase();
922
+ return host && repoPath ? `${host}/${repoPath}` : "";
923
+ }
924
+
925
+ try {
926
+ const parsed = new URL(cleaned);
927
+ const host = String(parsed.hostname || "").trim().toLowerCase();
928
+ const repoPath = String(parsed.pathname || "")
929
+ .trim()
930
+ .replace(/^\/+/, "")
931
+ .replace(/\/+$/, "")
932
+ .toLowerCase();
933
+ return host && repoPath ? `${host}/${repoPath}` : "";
934
+ } catch {
935
+ return cleaned.toLowerCase();
936
+ }
937
+ }
938
+
939
+ function uniqueNormalizedStrings(values) {
940
+ const seen = new Set();
941
+ const ordered = [];
942
+ for (const value of Array.isArray(values) ? values : []) {
943
+ const text = String(value || "").trim();
944
+ if (!text) continue;
945
+ const key = text.toLowerCase();
946
+ if (seen.has(key)) continue;
947
+ seen.add(key);
948
+ ordered.push(text);
949
+ }
950
+ return ordered;
951
+ }
952
+
953
+ function normalizeRepoBindingEntry(binding, index = 0) {
954
+ if (!binding || typeof binding !== "object") return null;
955
+
956
+ const role = String(binding.role || "").trim().toLowerCase();
957
+ const canonicalRepoName = String(binding.canonical_repo_name || "").trim();
958
+ const canonicalRemoteUrl = String(binding.canonical_remote_url || "").trim();
959
+ const clientRepoName = String(binding.client_repo_name || "").trim();
960
+ const clientRemoteUrl = String(binding.client_remote_url || "").trim();
961
+ const localRepoAliases = uniqueNormalizedStrings(binding.local_repo_aliases || []);
962
+ const baselineBranch = String(binding.baseline_branch || "").trim();
963
+
964
+ const remoteFingerprints = uniqueNormalizedStrings([
965
+ ...(Array.isArray(binding.remote_fingerprints) ? binding.remote_fingerprints : []),
966
+ normalizeRemoteFingerprint(canonicalRemoteUrl),
967
+ normalizeRemoteFingerprint(clientRemoteUrl),
968
+ ]);
969
+
970
+ const matchingNames = uniqueNormalizedStrings([
971
+ ...(Array.isArray(binding.matching_names) ? binding.matching_names : []),
972
+ canonicalRepoName,
973
+ clientRepoName,
974
+ ...localRepoAliases,
975
+ ]);
976
+
977
+ if (!role && !matchingNames.length && !remoteFingerprints.length) return null;
978
+
979
+ return {
980
+ role: role || `repo_${index + 1}`,
981
+ canonical_repo_name: canonicalRepoName || null,
982
+ canonical_remote_url: canonicalRemoteUrl || null,
983
+ client_repo_name: clientRepoName || null,
984
+ client_remote_url: clientRemoteUrl || null,
985
+ local_repo_aliases: localRepoAliases,
986
+ baseline_branch: baselineBranch || null,
987
+ matching_names: matchingNames,
988
+ remote_fingerprints: remoteFingerprints,
989
+ };
990
+ }
991
+
992
+ function normalizeRepoBindings(bindings) {
993
+ const normalized = [];
994
+ const seenRoles = new Set();
995
+ for (const [index, binding] of (Array.isArray(bindings) ? bindings : []).entries()) {
996
+ const item = normalizeRepoBindingEntry(binding, index);
997
+ if (!item) continue;
998
+ const roleKey = String(item.role || "").trim().toLowerCase();
999
+ if (seenRoles.has(roleKey)) continue;
1000
+ seenRoles.add(roleKey);
1001
+ normalized.push(item);
1002
+ }
1003
+ return normalized;
1004
+ }
1005
+
1006
+ function getRepoRemoteFingerprints(repoPath) {
1007
+ const output = runGitTry(repoPath, ["remote", "-v"]);
1008
+ if (!output) return [];
1009
+ const fingerprints = [];
1010
+ for (const line of String(output).split(/\r?\n/)) {
1011
+ const parts = line.trim().split(/\s+/);
1012
+ if (parts.length < 2) continue;
1013
+ const normalized = normalizeRemoteFingerprint(parts[1]);
1014
+ if (normalized) fingerprints.push(normalized);
1015
+ }
1016
+ return uniqueNormalizedStrings(fingerprints);
1017
+ }
1018
+
1019
+ function listGitRepoCandidates(searchRoot, currentRepoRoot) {
1020
+ const candidates = [];
1021
+ const seen = new Set();
1022
+
1023
+ function pushCandidate(absPath, rootForDir) {
1024
+ const abs = path.resolve(absPath);
1025
+ if (!hasGitDir(abs) || seen.has(abs)) return;
1026
+ seen.add(abs);
1027
+ const rootBase = rootForDir ? path.resolve(rootForDir) : path.dirname(abs);
1028
+ const rel = path.relative(rootBase, abs);
1029
+ const dir = rel && rel !== "" ? rel : ".";
1030
+ const repoName = path.basename(abs);
1031
+ candidates.push({
1032
+ abs,
1033
+ dir,
1034
+ name: repoName,
1035
+ prefix: `${repoName.replace(/\\\\/g, "/")}/`,
1036
+ remote_fingerprints: getRepoRemoteFingerprints(abs),
1037
+ is_current_repo: currentRepoRoot ? path.resolve(currentRepoRoot) === abs : false,
1038
+ });
1039
+ }
1040
+
1041
+ if (currentRepoRoot) {
1042
+ pushCandidate(currentRepoRoot, path.dirname(currentRepoRoot));
1043
+ }
1044
+
1045
+ pushCandidate(searchRoot, path.dirname(searchRoot));
1046
+
1047
+ let entries = [];
1048
+ try {
1049
+ entries = fs.readdirSync(searchRoot, { withFileTypes: true });
1050
+ } catch {
1051
+ entries = [];
1052
+ }
1053
+ for (const entry of entries) {
1054
+ if (!entry || !entry.isDirectory || !entry.isDirectory()) continue;
1055
+ const abs = path.join(searchRoot, entry.name);
1056
+ pushCandidate(abs, searchRoot);
1057
+ }
1058
+
1059
+ return candidates;
1060
+ }
1061
+
1062
+ function scoreBindingCandidate(binding, candidate) {
1063
+ const candidateName = String(candidate?.name || "").trim().toLowerCase();
1064
+ const bindingNames = new Set((binding?.matching_names || []).map((name) => String(name || "").trim().toLowerCase()).filter(Boolean));
1065
+ const bindingRemotes = new Set((binding?.remote_fingerprints || []).map((value) => String(value || "").trim().toLowerCase()).filter(Boolean));
1066
+ const candidateRemotes = new Set((candidate?.remote_fingerprints || []).map((value) => String(value || "").trim().toLowerCase()).filter(Boolean));
1067
+ const canonicalRemoteFingerprint = normalizeRemoteFingerprint(binding?.canonical_remote_url).toLowerCase();
1068
+ const clientRemoteFingerprint = normalizeRemoteFingerprint(binding?.client_remote_url).toLowerCase();
1069
+
1070
+ let score = 0;
1071
+ const matchedBy = [];
1072
+
1073
+ if (binding.canonical_repo_name && candidateName === String(binding.canonical_repo_name).trim().toLowerCase()) {
1074
+ score = Math.max(score, 240);
1075
+ matchedBy.push("canonical_name");
1076
+ }
1077
+ if (binding.client_repo_name && candidateName === String(binding.client_repo_name).trim().toLowerCase()) {
1078
+ score = Math.max(score, 260);
1079
+ matchedBy.push("client_name");
1080
+ }
1081
+ if (bindingNames.has(candidateName)) {
1082
+ score = Math.max(score, 200);
1083
+ matchedBy.push("alias");
1084
+ }
1085
+ for (const remote of candidateRemotes) {
1086
+ if (clientRemoteFingerprint && remote === clientRemoteFingerprint) {
1087
+ score = Math.max(score, 340);
1088
+ matchedBy.push("client_remote");
1089
+ break;
1090
+ }
1091
+ if (canonicalRemoteFingerprint && remote === canonicalRemoteFingerprint) {
1092
+ score = Math.max(score, 300);
1093
+ matchedBy.push("canonical_remote");
1094
+ break;
1095
+ }
1096
+ if (bindingRemotes.has(remote)) {
1097
+ score = Math.max(score, 320);
1098
+ matchedBy.push("remote");
1099
+ break;
1100
+ }
1101
+ }
1102
+
1103
+ return { score, matchedBy: uniqueNormalizedStrings(matchedBy) };
1104
+ }
1105
+
1106
+ function resolveConfiguredRepoBindings(bindings) {
1107
+ const normalizedBindings = normalizeRepoBindings(bindings);
1108
+ if (!normalizedBindings.length) {
1109
+ return { mode: "none", root: null, repos: [], missing: [], bindings: [] };
1110
+ }
1111
+
1112
+ const cwd = process.cwd();
1113
+ const currentRepoRoot = findGitTopLevel(cwd);
1114
+ const searchRoots = [];
1115
+ const seenRoots = new Set();
1116
+ const pushSearchRoot = (value) => {
1117
+ if (!value) return;
1118
+ const resolved = path.resolve(value);
1119
+ if (seenRoots.has(resolved)) return;
1120
+ seenRoots.add(resolved);
1121
+ searchRoots.push(resolved);
1122
+ };
1123
+
1124
+ // Prefer the invocation workspace first. A wrapper repo can contain the
1125
+ // configured API/WEB repos as children, so jumping immediately to the parent
1126
+ // can miss the project repos entirely.
1127
+ pushSearchRoot(cwd);
1128
+ if (currentRepoRoot) pushSearchRoot(currentRepoRoot);
1129
+
1130
+ const scanStart = currentRepoRoot ? path.dirname(currentRepoRoot) : path.dirname(cwd);
1131
+ let cur = scanStart;
1132
+ for (let i = 0; i < 8; i += 1) {
1133
+ pushSearchRoot(cur);
1134
+ const parent = path.dirname(cur);
1135
+ if (parent === cur) break;
1136
+ cur = parent;
1137
+ }
1138
+
1139
+ let fallbackCandidates = [];
1140
+ let fallbackRoot = null;
1141
+
1142
+ for (const candidateRoot of searchRoots) {
1143
+ const candidates = listGitRepoCandidates(candidateRoot, currentRepoRoot);
1144
+ if (!fallbackCandidates.length && candidates.length) {
1145
+ fallbackCandidates = candidates;
1146
+ fallbackRoot = candidateRoot;
1147
+ }
1148
+
1149
+ const assigned = new Set();
1150
+ const found = [];
1151
+ for (const binding of normalizedBindings) {
1152
+ let best = null;
1153
+ for (const candidate of candidates) {
1154
+ if (assigned.has(candidate.abs)) continue;
1155
+ const match = scoreBindingCandidate(binding, candidate);
1156
+ if (!match.score) continue;
1157
+ if (!best || match.score > best.match.score) {
1158
+ best = { candidate, match };
1159
+ }
1160
+ }
1161
+ if (!best) continue;
1162
+ assigned.add(best.candidate.abs);
1163
+ found.push({
1164
+ name: best.candidate.name,
1165
+ dir: best.candidate.dir,
1166
+ abs: best.candidate.abs,
1167
+ prefix: best.candidate.prefix,
1168
+ role: binding.role,
1169
+ canonical_repo_name: binding.canonical_repo_name,
1170
+ canonical_remote_url: binding.canonical_remote_url,
1171
+ client_repo_name: binding.client_repo_name,
1172
+ client_remote_url: binding.client_remote_url,
1173
+ baseline_branch: binding.baseline_branch || null,
1174
+ matched_by: best.match.matchedBy,
1175
+ remote_fingerprints: best.candidate.remote_fingerprints,
1176
+ });
1177
+ }
1178
+
1179
+ if (found.length) {
1180
+ const foundRoles = new Set(found.map((repo) => repo.role));
1181
+ const missing = normalizedBindings
1182
+ .filter((binding) => !foundRoles.has(binding.role))
1183
+ .map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role);
1184
+ return {
1185
+ mode: currentRepoRoot && found.length === 1 && found[0].abs === currentRepoRoot ? "repo" : "workspace",
1186
+ root: candidateRoot,
1187
+ repos: found,
1188
+ missing,
1189
+ bindings: normalizedBindings,
1190
+ };
1191
+ }
1192
+ }
1193
+
1194
+ return {
1195
+ mode: currentRepoRoot ? "repo" : "none",
1196
+ root: fallbackRoot,
1197
+ repos: [],
1198
+ missing: normalizedBindings.map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role),
1199
+ bindings: normalizedBindings,
1200
+ };
1201
+ }
1202
+
1203
+ function fetchBaseBranches(repoPath, preferredBranch) {
1204
+ const preferred = String(preferredBranch || "").trim();
1205
+ if (preferred && runGitOk(repoPath, ["fetch", "origin", preferred, "--prune", "--quiet"])) return true;
837
1206
  if (runGitOk(repoPath, ["fetch", "origin", "main", "master", "--prune", "--quiet"])) return true;
838
1207
  return runGitOk(repoPath, ["fetch", "--all", "--prune", "--quiet"]);
839
1208
  }
840
1209
 
841
- function resolveBaseRef(repoPath) {
1210
+ function buildExternalBaseRefName(remoteUrl, branch) {
1211
+ const branchSlug = String(branch || "main")
1212
+ .trim()
1213
+ .toLowerCase()
1214
+ .replace(/[^a-z0-9._-]+/g, "-")
1215
+ .replace(/^-+|-+$/g, "") || "main";
1216
+ const digest = createHash("sha1")
1217
+ .update(`${String(remoteUrl || "").trim()}::${branchSlug}`)
1218
+ .digest("hex")
1219
+ .slice(0, 12);
1220
+ return `refs/myte/bases/${branchSlug}-${digest}`;
1221
+ }
1222
+
1223
+ function fetchExternalBaseRef(repoPath, remoteUrl, preferredBranch) {
1224
+ const remote = String(remoteUrl || "").trim();
1225
+ if (!remote) return null;
1226
+
1227
+ const candidateBranches = uniqueNormalizedStrings([preferredBranch, "main", "master"]);
1228
+ for (const branch of candidateBranches) {
1229
+ const refName = buildExternalBaseRefName(remote, branch);
1230
+ if (runGitOk(repoPath, ["fetch", "--quiet", "--no-tags", remote, `${branch}:${refName}`])) {
1231
+ return { ref: refName, branch };
1232
+ }
1233
+ }
1234
+ return null;
1235
+ }
1236
+
1237
+ function resolveExternalCompareRemoteUrl(repo) {
1238
+ const canonicalRemoteUrl = String(repo?.canonical_remote_url || "").trim();
1239
+ const clientRemoteUrl = String(repo?.client_remote_url || "").trim();
1240
+ if (!canonicalRemoteUrl || !clientRemoteUrl) return null;
1241
+
1242
+ const canonicalFingerprint = normalizeRemoteFingerprint(canonicalRemoteUrl);
1243
+ const clientFingerprint = normalizeRemoteFingerprint(clientRemoteUrl);
1244
+ if (!canonicalFingerprint || !clientFingerprint || canonicalFingerprint === clientFingerprint) return null;
1245
+
1246
+ const candidateFingerprints = new Set(
1247
+ (Array.isArray(repo?.remote_fingerprints) ? repo.remote_fingerprints : [])
1248
+ .map((value) => String(value || "").trim().toLowerCase())
1249
+ .filter(Boolean)
1250
+ );
1251
+ if (!candidateFingerprints.has(clientFingerprint.toLowerCase())) return null;
1252
+ if (candidateFingerprints.has(canonicalFingerprint.toLowerCase())) return null;
1253
+
1254
+ return canonicalRemoteUrl;
1255
+ }
1256
+
1257
+ function resolveBaseRef(repoPath, preferredBranch) {
1258
+ const preferred = String(preferredBranch || "").trim();
1259
+ if (preferred) {
1260
+ if (runGitOk(repoPath, ["rev-parse", "--verify", `refs/remotes/origin/${preferred}`])) return `origin/${preferred}`;
1261
+ if (runGitOk(repoPath, ["rev-parse", "--verify", `refs/heads/${preferred}`])) return preferred;
1262
+ }
842
1263
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/remotes/origin/main"])) return "origin/main";
843
1264
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/remotes/origin/master"])) return "origin/master";
844
1265
  if (runGitOk(repoPath, ["rev-parse", "--verify", "refs/heads/main"])) return "main";
@@ -849,13 +1270,24 @@ function resolveBaseRef(repoPath) {
849
1270
  function collectGitDiffWithDiagnostics({
850
1271
  projectId,
851
1272
  repoNames,
1273
+ repoBindings,
852
1274
  maxChars,
853
1275
  fetchRemote = true,
854
1276
  } = {}) {
855
1277
  const configuredRepos = Array.isArray(repoNames) ? repoNames.map(String).map((s) => s.trim()).filter(Boolean) : [];
1278
+ const configuredBindings = normalizeRepoBindings(repoBindings);
856
1279
  const diagnostics = {
857
1280
  project_id: projectId || null,
858
1281
  requested_repo_names: configuredRepos,
1282
+ requested_repo_bindings: configuredBindings.map((binding) => ({
1283
+ role: binding.role,
1284
+ canonical_repo_name: binding.canonical_repo_name,
1285
+ canonical_remote_url: binding.canonical_remote_url,
1286
+ client_repo_name: binding.client_repo_name,
1287
+ client_remote_url: binding.client_remote_url,
1288
+ local_repo_aliases: binding.local_repo_aliases,
1289
+ baseline_branch: binding.baseline_branch,
1290
+ })),
859
1291
  fetch_remote: Boolean(fetchRemote),
860
1292
  mode: "none",
861
1293
  search_root: null,
@@ -870,7 +1302,9 @@ function collectGitDiffWithDiagnostics({
870
1302
  };
871
1303
 
872
1304
  try {
873
- const resolved = resolveConfiguredRepos(configuredRepos);
1305
+ const resolved = configuredBindings.length
1306
+ ? resolveConfiguredRepoBindings(configuredBindings)
1307
+ : resolveConfiguredRepos(configuredRepos);
874
1308
  diagnostics.mode = resolved.mode || "none";
875
1309
  diagnostics.search_root = resolved.root || null;
876
1310
  diagnostics.found_repos = (resolved.repos || []).map((r) => r.name);
@@ -888,7 +1322,15 @@ function collectGitDiffWithDiagnostics({
888
1322
  const sections = [];
889
1323
  if (projectId) sections.push(`# Project: ${projectId}`);
890
1324
  sections.push(`# Mode: ${resolved.mode}`);
891
- sections.push(`# Configured repos: ${configuredRepos.join(", ") || ""}`);
1325
+ if (configuredBindings.length) {
1326
+ sections.push(
1327
+ `# Configured repo bindings: ${configuredBindings
1328
+ .map((binding) => `${binding.role}=${binding.client_repo_name || binding.canonical_repo_name || binding.role}`)
1329
+ .join(", ")}`
1330
+ );
1331
+ } else {
1332
+ sections.push(`# Configured repos: ${configuredRepos.join(", ") || ""}`);
1333
+ }
892
1334
  if (resolved.missing && resolved.missing.length) {
893
1335
  sections.push(`# Missing locally (skipped): ${resolved.missing.join(", ")}`);
894
1336
  }
@@ -900,8 +1342,12 @@ function collectGitDiffWithDiagnostics({
900
1342
  dir: repo.dir || ".",
901
1343
  root: repo.abs,
902
1344
  status: "ok",
1345
+ role: repo.role || null,
903
1346
  head_branch: null,
904
1347
  base_ref: null,
1348
+ base_ref_label: null,
1349
+ compare_mode: "local_origin",
1350
+ compare_remote_url: null,
905
1351
  has_changes: false,
906
1352
  changed_blocks: {
907
1353
  base_vs_head: false,
@@ -914,22 +1360,55 @@ function collectGitDiffWithDiagnostics({
914
1360
  };
915
1361
 
916
1362
  const { name, dir, abs, prefix } = repo;
917
- const fetchDiag = { attempted: false, ok: false, message: "" };
1363
+ const externalCompareRemoteUrl = resolveExternalCompareRemoteUrl(repo);
1364
+ const fetchDiag = {
1365
+ attempted: false,
1366
+ ok: false,
1367
+ message: "",
1368
+ compare_mode: externalCompareRemoteUrl ? "external_remote" : "local_origin",
1369
+ compare_remote_url: externalCompareRemoteUrl || null,
1370
+ };
1371
+ let baseRef = null;
1372
+ let baseRefLabel = null;
918
1373
  if (fetchRemote) {
919
1374
  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}`);
1375
+ if (externalCompareRemoteUrl) {
1376
+ const externalBase = fetchExternalBaseRef(abs, externalCompareRemoteUrl, repo.baseline_branch);
1377
+ fetchDiag.ok = Boolean(externalBase?.ref);
1378
+ if (externalBase?.ref) {
1379
+ baseRef = externalBase.ref;
1380
+ baseRefLabel = `${externalBase.branch}@canonical`;
1381
+ } else {
1382
+ fetchDiag.message = `failed to refresh canonical compare branch from ${externalCompareRemoteUrl}`;
1383
+ repoSummary.errors.push(fetchDiag.message);
1384
+ diagnostics.warnings.push(`Repo "${name}": ${fetchDiag.message}`);
1385
+ }
1386
+ } else {
1387
+ fetchDiag.ok = fetchBaseBranches(abs, repo.baseline_branch);
1388
+ if (!fetchDiag.ok) {
1389
+ fetchDiag.message = "failed to refresh origin main/master";
1390
+ repoSummary.errors.push(fetchDiag.message);
1391
+ diagnostics.warnings.push(`Repo "${name}": ${fetchDiag.message}`);
1392
+ }
925
1393
  }
926
1394
  }
927
1395
 
928
1396
  const headBranch = runGitTry(abs, ["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
929
- const baseRef = resolveBaseRef(abs);
1397
+ if (!baseRef && !externalCompareRemoteUrl) {
1398
+ baseRef = resolveBaseRef(abs, repo.baseline_branch);
1399
+ }
930
1400
  repoSummary.head_branch = headBranch;
931
1401
  repoSummary.base_ref = baseRef || "base-unresolved";
1402
+ repoSummary.base_ref_label = baseRefLabel || repoSummary.base_ref;
932
1403
  repoSummary.fetch = fetchDiag;
1404
+ repoSummary.compare_mode = fetchDiag.compare_mode;
1405
+ repoSummary.compare_remote_url = fetchDiag.compare_remote_url;
1406
+ if (repo.matched_by) repoSummary.matched_by = repo.matched_by;
1407
+ if (repo.baseline_branch) repoSummary.baseline_branch = repo.baseline_branch;
1408
+ if (repo.canonical_repo_name) repoSummary.canonical_repo_name = repo.canonical_repo_name;
1409
+ if (repo.canonical_remote_url) repoSummary.canonical_remote_url = repo.canonical_remote_url;
1410
+ if (repo.client_repo_name) repoSummary.client_repo_name = repo.client_repo_name;
1411
+ if (repo.client_remote_url) repoSummary.client_remote_url = repo.client_remote_url;
933
1412
 
934
1413
  let baseDiff = "";
935
1414
  if (baseRef) {
@@ -999,8 +1478,16 @@ function collectGitDiffWithDiagnostics({
999
1478
  repoSummary.status = "partial";
1000
1479
  }
1001
1480
 
1002
- let section = `### ${name} (${dir || "."})\n\n`;
1003
- section += `# ?? Base vs HEAD (${repoSummary.base_ref}...HEAD | head=${headBranch})\n`;
1481
+ const headingParts = [`${name} (${dir || "."})`];
1482
+ if (repo.role) headingParts.push(`role=${repo.role}`);
1483
+ let section = `### ${headingParts.join(" | ")}\n\n`;
1484
+ section += `# ?? Base vs HEAD (${repoSummary.base_ref_label}...HEAD | head=${headBranch})\n`;
1485
+ if (repoSummary.matched_by?.length) {
1486
+ section += `# ?? Matched by ${repoSummary.matched_by.join(", ")}\n`;
1487
+ }
1488
+ if (repoSummary.compare_remote_url) {
1489
+ section += `# ?? Compare remote ${repoSummary.compare_remote_url}\n`;
1490
+ }
1004
1491
  if (baseDiff) section += `${baseDiff}\n\n`;
1005
1492
  if (staged) section += `# ?? Staged changes\n${staged}\n\n`;
1006
1493
  if (unstaged) section += `# ?? Unstaged changes\n${unstaged}\n\n`;
@@ -1144,6 +1631,83 @@ async function fetchFeedbackSyncSnapshot({ apiBase, key, timeoutMs, filters = {}
1144
1631
  return body.data || {};
1145
1632
  }
1146
1633
 
1634
+ async function fetchFeedbackReview({ apiBase, key, timeoutMs, feedbackId }) {
1635
+ const fetchFn = await getFetch();
1636
+ const url = `${apiBase}/project-assistant/feedback/${encodeURIComponent(String(feedbackId || ""))}`;
1637
+ const { resp, body } = await fetchJsonWithTimeout(
1638
+ fetchFn,
1639
+ url,
1640
+ {
1641
+ method: "GET",
1642
+ headers: { Authorization: `Bearer ${key}` },
1643
+ },
1644
+ timeoutMs
1645
+ );
1646
+
1647
+ if (!resp.ok || body.status !== "success") {
1648
+ const msg = body?.message || `Feedback review request failed (${resp.status})`;
1649
+ const err = new Error(msg);
1650
+ err.status = resp.status;
1651
+ throw err;
1652
+ }
1653
+ return body.data || {};
1654
+ }
1655
+
1656
+ async function fetchFeedbackHistory({ apiBase, key, timeoutMs, feedbackId }) {
1657
+ const fetchFn = await getFetch();
1658
+ const url = `${apiBase}/project-assistant/feedback/${encodeURIComponent(String(feedbackId || ""))}/refinement/history`;
1659
+ const { resp, body } = await fetchJsonWithTimeout(
1660
+ fetchFn,
1661
+ url,
1662
+ {
1663
+ method: "GET",
1664
+ headers: { Authorization: `Bearer ${key}` },
1665
+ },
1666
+ timeoutMs
1667
+ );
1668
+
1669
+ if (!resp.ok || body.status !== "success") {
1670
+ const msg = body?.message || `Feedback history request failed (${resp.status})`;
1671
+ const err = new Error(msg);
1672
+ err.status = resp.status;
1673
+ throw err;
1674
+ }
1675
+ return body.data || {};
1676
+ }
1677
+
1678
+ async function postFeedbackRefinement({ apiBase, key, timeoutMs, feedbackId, mode, payload, idempotencyKey, clientSessionId }) {
1679
+ const fetchFn = await getFetch();
1680
+ const action = String(mode || "").trim();
1681
+ const url = `${apiBase}/project-assistant/feedback/${encodeURIComponent(String(feedbackId || ""))}/refinement/${action}`;
1682
+ const headers = {
1683
+ "Content-Type": "application/json",
1684
+ Authorization: `Bearer ${key}`,
1685
+ ...(String(clientSessionId || "").trim() ? { "X-Client-Session-Id": String(clientSessionId).trim() } : {}),
1686
+ };
1687
+ if (idempotencyKey) {
1688
+ headers["X-Idempotency-Key"] = String(idempotencyKey).trim();
1689
+ }
1690
+ const { resp, body } = await fetchJsonWithTimeout(
1691
+ fetchFn,
1692
+ url,
1693
+ {
1694
+ method: "POST",
1695
+ headers,
1696
+ body: JSON.stringify(payload || {}),
1697
+ },
1698
+ timeoutMs
1699
+ );
1700
+
1701
+ if (!resp.ok || body.status !== "success") {
1702
+ const msg = body?.message || `Feedback refinement ${action} failed (${resp.status})`;
1703
+ const err = new Error(msg);
1704
+ err.status = resp.status;
1705
+ err.data = body?.data;
1706
+ throw err;
1707
+ }
1708
+ return body.data || {};
1709
+ }
1710
+
1147
1711
  async function fetchSuggestionsSyncSnapshot({ apiBase, key, timeoutMs, actorScope = "" }) {
1148
1712
  const fetchFn = await getFetch();
1149
1713
  const url = new URL(`${apiBase}/project-assistant/suggestions`);
@@ -1469,7 +2033,7 @@ async function runRunQaqc(args) {
1469
2033
 
1470
2034
  let resolved;
1471
2035
  try {
1472
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
2036
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
1473
2037
  } catch (err) {
1474
2038
  console.error(err?.message || err);
1475
2039
  process.exit(1);
@@ -2063,6 +2627,20 @@ function resolveBootstrapWorkspace(repoNames) {
2063
2627
  return resolved;
2064
2628
  }
2065
2629
 
2630
+ function resolvePortableWorkspace(repoNames) {
2631
+ const configured = Array.isArray(repoNames) ? repoNames.map(String).map((s) => s.trim()).filter(Boolean) : [];
2632
+ const resolved = resolveConfiguredRepos(configured);
2633
+ if (resolved.root) {
2634
+ return resolved;
2635
+ }
2636
+ return {
2637
+ mode: "cwd",
2638
+ root: process.cwd(),
2639
+ repos: [],
2640
+ missing: configured,
2641
+ };
2642
+ }
2643
+
2066
2644
  function resolveCommandCenterRoots(wrapperRoot, outputDir) {
2067
2645
  const targetRoot = outputDir
2068
2646
  ? path.resolve(process.cwd(), String(outputDir))
@@ -2301,6 +2879,150 @@ function writeFeedbackSnapshot({ snapshot, wrapperRoot, outputDir }) {
2301
2879
  };
2302
2880
  }
2303
2881
 
2882
+ function resolveFeedbackReviewPaths(args, { requireFeedback = false } = {}) {
2883
+ const outputDir = args["output-dir"] || args.outputDir || args.output_dir;
2884
+ const targetRoot = outputDir
2885
+ ? path.resolve(process.cwd(), String(outputDir))
2886
+ : findExistingCommandCenterRoot(process.cwd());
2887
+ if (!targetRoot) {
2888
+ if (requireFeedback) {
2889
+ console.error("feedback.yml was not found. Run `myte feedback-sync` first or pass --output-dir.");
2890
+ process.exit(1);
2891
+ }
2892
+ return null;
2893
+ }
2894
+ const dataRoot = path.join(targetRoot, "data");
2895
+ const feedbackPath = path.join(dataRoot, "feedback.yml");
2896
+ if (requireFeedback && !fs.existsSync(feedbackPath)) {
2897
+ console.error("feedback.yml was not found. Run `myte feedback-sync` first or pass --output-dir.");
2898
+ process.exit(1);
2899
+ }
2900
+ return {
2901
+ targetRoot,
2902
+ dataRoot,
2903
+ feedbackPath,
2904
+ reviewsRoot: path.join(targetRoot, "reviews", "feedback"),
2905
+ wrapperRoot: path.dirname(targetRoot),
2906
+ };
2907
+ }
2908
+
2909
+ function readFeedbackManifest(args) {
2910
+ const paths = resolveFeedbackReviewPaths(args, { requireFeedback: true });
2911
+ const manifest = readYamlFile(paths.feedbackPath) || {};
2912
+ if (!isPlainObject(manifest)) {
2913
+ console.error(`Invalid feedback manifest: ${paths.feedbackPath}`);
2914
+ process.exit(1);
2915
+ }
2916
+ return { paths, manifest };
2917
+ }
2918
+
2919
+ function findFeedbackManifestItem(manifest, feedbackId) {
2920
+ const wanted = String(feedbackId || "").trim();
2921
+ if (!wanted) return null;
2922
+ const items = Array.isArray(manifest?.items) ? manifest.items : [];
2923
+ return items.find((item) => String(item?.feedback_id || item?.id || "").trim() === wanted) || null;
2924
+ }
2925
+
2926
+ function readFeedbackRefinementArtifact(args) {
2927
+ const filePath = firstNonEmptyString(args.file);
2928
+ if (!filePath) {
2929
+ console.error("Missing --file for feedback validate/apply.");
2930
+ process.exit(1);
2931
+ }
2932
+ const absPath = resolveInputFile(filePath, "Feedback refinement");
2933
+ const text = fs.readFileSync(absPath, "utf8");
2934
+ let payload;
2935
+ if (absPath.toLowerCase().endsWith(".json")) {
2936
+ payload = JSON.parse(text);
2937
+ } else {
2938
+ payload = parseYaml(text);
2939
+ }
2940
+ if (!isPlainObject(payload)) {
2941
+ console.error(`Feedback refinement file must contain an object: ${absPath}`);
2942
+ process.exit(1);
2943
+ }
2944
+ return { absPath, payload };
2945
+ }
2946
+
2947
+ function parseFeedbackTags(args) {
2948
+ const values = parseCsvValues(args.tags, args.tag);
2949
+ return values.length ? values : undefined;
2950
+ }
2951
+
2952
+ function feedbackChange(from, to) {
2953
+ return { from: from === undefined ? null : from, to: to === undefined ? null : to };
2954
+ }
2955
+
2956
+ function buildFeedbackRefinementArtifact({ manifest, item, feedbackId, action, changes, reason, args }) {
2957
+ const clientSessionId = firstNonEmptyString(args["client-session-id"], args.clientSessionId, args.client_session_id);
2958
+ const artifact = {
2959
+ schema_version: 1,
2960
+ kind: "feedback_refinement",
2961
+ project_id: firstNonEmptyString(manifest?.project?.id, item?.project_id),
2962
+ feedback_id: feedbackId,
2963
+ title_snapshot: firstNonEmptyString(item?.title),
2964
+ base_snapshot_hash: firstNonEmptyString(item?.snapshot_hash),
2965
+ base_updated_at: firstNonEmptyString(item?.updated_at),
2966
+ review_action: action,
2967
+ reason: reason || null,
2968
+ changes,
2969
+ force: Boolean(args.force) || undefined,
2970
+ client_session_id: clientSessionId || undefined,
2971
+ created_at: new Date().toISOString(),
2972
+ };
2973
+ return Object.fromEntries(Object.entries(artifact).filter(([, value]) => value !== undefined));
2974
+ }
2975
+
2976
+ function writeFeedbackRefinementArtifact({ paths, artifact, action }) {
2977
+ const feedbackId = sanitizeFileSegment(artifact.feedback_id, "feedback");
2978
+ const actionSegment = sanitizeFileSegment(action || artifact.review_action || "refine", "refine");
2979
+ const filePath = path.join(paths.reviewsRoot, `${feedbackId}-${actionSegment}.yml`);
2980
+ writeYamlFile(filePath, artifact);
2981
+ return filePath;
2982
+ }
2983
+
2984
+ function printFeedbackRefinementDraft({ artifact, filePath, args }) {
2985
+ const output = {
2986
+ feedback_id: artifact.feedback_id,
2987
+ review_action: artifact.review_action,
2988
+ artifact_path: filePath || null,
2989
+ base_snapshot_hash: artifact.base_snapshot_hash || null,
2990
+ changes: artifact.changes || {},
2991
+ reason: artifact.reason || null,
2992
+ };
2993
+ if (args.json) {
2994
+ console.log(JSON.stringify(output, null, 2));
2995
+ return;
2996
+ }
2997
+ console.log(`Feedback: ${output.feedback_id}`);
2998
+ console.log(`Action: ${output.review_action}`);
2999
+ if (filePath) console.log(`Artifact: ${filePath}`);
3000
+ console.log(`Base Snapshot: ${output.base_snapshot_hash || "n/a"}`);
3001
+ console.log("Changes:");
3002
+ for (const [field, change] of Object.entries(output.changes || {})) {
3003
+ const fromValue = change && typeof change === "object" && "from" in change ? change.from : null;
3004
+ const toValue = change && typeof change === "object" && "to" in change ? change.to : change;
3005
+ console.log(`- ${field}: ${JSON.stringify(fromValue)} -> ${JSON.stringify(toValue)}`);
3006
+ }
3007
+ if (output.reason) console.log(`Reason: ${output.reason}`);
3008
+ }
3009
+
3010
+ function summarizeFeedbackRefinementResult(data) {
3011
+ return {
3012
+ valid: Boolean(data?.valid),
3013
+ feedback_id: data?.feedback_id || data?.feedback?.feedback_id || null,
3014
+ project_id: data?.project_id || null,
3015
+ previous_snapshot_hash: data?.previous_snapshot_hash || data?.base_snapshot_hash || null,
3016
+ current_snapshot_hash: data?.current_snapshot_hash || null,
3017
+ new_snapshot_hash: data?.new_snapshot_hash || data?.feedback?.snapshot_hash || null,
3018
+ diff_count: Array.isArray(data?.diffs) ? data.diffs.length : 0,
3019
+ diffs: data?.diffs || [],
3020
+ warnings: data?.warnings || [],
3021
+ errors: data?.errors || [],
3022
+ history_id: data?.history?.history_id || null,
3023
+ };
3024
+ }
3025
+
2304
3026
  function missionOpsThreads(payload) {
2305
3027
  if (Array.isArray(payload?.threads)) return payload.threads;
2306
3028
  if (Array.isArray(payload?.suggestions)) return payload.suggestions;
@@ -2545,7 +3267,7 @@ async function resolveSuggestionsOutputContext({ args, key, apiBase, timeoutMs,
2545
3267
  const repoNames = Array.isArray(snapshot?.repo_names) && snapshot.repo_names.length
2546
3268
  ? snapshot.repo_names
2547
3269
  : (await fetchProjectConfig({ apiBase, key, timeoutMs })).repo_names || [];
2548
- const resolved = resolveBootstrapWorkspace(repoNames);
3270
+ const resolved = resolvePortableWorkspace(repoNames);
2549
3271
  return {
2550
3272
  wrapperRoot: resolved.root,
2551
3273
  targetRoot: path.join(resolved.root, "MyteCommandCenter"),
@@ -3155,11 +3877,15 @@ async function runConfig(args) {
3155
3877
  }
3156
3878
 
3157
3879
  const repoNames = Array.isArray(cfg.repo_names) ? cfg.repo_names : [];
3158
- const resolved = resolveConfiguredRepos(repoNames);
3880
+ const repoBindings = Array.isArray(cfg.repo_bindings) ? cfg.repo_bindings : [];
3881
+ const resolved = repoBindings.length
3882
+ ? resolveConfiguredRepoBindings(repoBindings)
3883
+ : resolveConfiguredRepos(repoNames);
3159
3884
  const payload = {
3160
3885
  api_base: apiBase,
3161
3886
  project_id: cfg.project_id,
3162
3887
  repo_names: repoNames,
3888
+ repo_bindings: repoBindings,
3163
3889
  local: {
3164
3890
  mode: resolved.mode,
3165
3891
  root: resolved.root,
@@ -3174,6 +3900,13 @@ async function runConfig(args) {
3174
3900
  console.log(`Project: ${payload.project_id || "(unknown)"}`);
3175
3901
  console.log(`API base: ${payload.api_base}`);
3176
3902
  console.log(`Configured repos: ${repoNames.join(", ") || "(none)"}`);
3903
+ if (repoBindings.length) {
3904
+ console.log(
3905
+ `Repo bindings: ${repoBindings
3906
+ .map((binding) => `${binding.role}=${binding.client_repo_name || binding.canonical_repo_name || binding.role}`)
3907
+ .join(", ")}`
3908
+ );
3909
+ }
3177
3910
  console.log(`Local mode: ${payload.local.mode}`);
3178
3911
  if (payload.local.found.length) console.log(`Found locally: ${payload.local.found.join(", ")}`);
3179
3912
  if (payload.local.missing.length) console.log(`Missing locally: ${payload.local.missing.join(", ")}`);
@@ -3209,7 +3942,7 @@ async function runBootstrap(args) {
3209
3942
 
3210
3943
  let resolved;
3211
3944
  try {
3212
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
3945
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3213
3946
  } catch (err) {
3214
3947
  console.error(err?.message || err);
3215
3948
  process.exit(1);
@@ -3303,7 +4036,7 @@ async function runSyncQaqc(args) {
3303
4036
 
3304
4037
  let resolved;
3305
4038
  try {
3306
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
4039
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3307
4040
  } catch (err) {
3308
4041
  console.error(err?.message || err);
3309
4042
  process.exit(1);
@@ -3397,7 +4130,7 @@ async function runFeedbackSync(args) {
3397
4130
 
3398
4131
  let resolved;
3399
4132
  try {
3400
- resolved = resolveBootstrapWorkspace(snapshot.repo_names || []);
4133
+ resolved = resolvePortableWorkspace(snapshot.repo_names || []);
3401
4134
  } catch (err) {
3402
4135
  console.error(err?.message || err);
3403
4136
  process.exit(1);
@@ -3463,6 +4196,251 @@ async function runFeedbackSync(args) {
3463
4196
  console.log(`Snapshot: ${summary.snapshot_hash || "n/a"}`);
3464
4197
  }
3465
4198
 
4199
+ function resolveFeedbackIdArg(args) {
4200
+ return firstNonEmptyString(args["feedback-id"], args.feedbackId, args.feedback_id, args._?.[1]);
4201
+ }
4202
+
4203
+ function requireFeedbackDraftContext(args) {
4204
+ const feedbackId = resolveFeedbackIdArg(args);
4205
+ if (!feedbackId) {
4206
+ console.error("Missing --feedback-id.");
4207
+ process.exit(1);
4208
+ }
4209
+ const { paths, manifest } = readFeedbackManifest(args);
4210
+ const item = findFeedbackManifestItem(manifest, feedbackId);
4211
+ if (!item) {
4212
+ console.error(`Feedback item not found in feedback.yml: ${feedbackId}. Run \`myte feedback-sync\` first.`);
4213
+ process.exit(1);
4214
+ }
4215
+ return { paths, manifest, item, feedbackId };
4216
+ }
4217
+
4218
+ async function buildFeedbackDraftForCommand(args, subcommand) {
4219
+ const { paths, manifest, item, feedbackId } = requireFeedbackDraftContext(args);
4220
+ const action = subcommand || "refine";
4221
+ const reason = firstNonEmptyString(args.reason);
4222
+ const changes = {};
4223
+
4224
+ if (action === "status") {
4225
+ const targetStatus = firstNonEmptyString(args.status, args._?.[2]);
4226
+ if (!targetStatus) {
4227
+ console.error("Missing --status for feedback status.");
4228
+ process.exit(1);
4229
+ }
4230
+ changes.feedback_state = feedbackChange(firstNonEmptyString(item.feedback_state, item.status), targetStatus);
4231
+ } else if (action === "archive") {
4232
+ changes.feedback_state = feedbackChange(firstNonEmptyString(item.feedback_state, item.status), "archived");
4233
+ } else if (action === "assign") {
4234
+ const assignee = firstNonEmptyString(args["assigned-user-id"], args.assignedUserId, args.assigned_user_id, args["user-id"], args.userId, args.user_id, args._?.[2]);
4235
+ if (!assignee) {
4236
+ console.error("Missing --user-id or --assigned-user-id for feedback assign.");
4237
+ process.exit(1);
4238
+ }
4239
+ changes.assigned_user_id = feedbackChange(firstNonEmptyString(item.assigned_user_id), assignee);
4240
+ } else if (action === "edit" || action === "refine") {
4241
+ const title = firstNonEmptyString(args.title);
4242
+ if (title) changes.title = feedbackChange(firstNonEmptyString(item.title), title);
4243
+
4244
+ let feedbackText = firstNonEmptyString(args["feedback-text"], args.feedbackText, args.feedback_text);
4245
+ const bodyFile = firstNonEmptyString(args["body-file"], args.bodyFile, args.body_file);
4246
+ if (!feedbackText && bodyFile) {
4247
+ const bodyPath = resolveInputFile(bodyFile, "Feedback body");
4248
+ feedbackText = fs.readFileSync(bodyPath, "utf8").trim();
4249
+ }
4250
+ if (feedbackText) changes.feedback_text = feedbackChange(firstNonEmptyString(item.feedback_text), feedbackText);
4251
+
4252
+ const priority = firstNonEmptyString(args.priority);
4253
+ if (priority) changes.priority = feedbackChange(firstNonEmptyString(item.priority), priority);
4254
+
4255
+ const dueDate = firstNonEmptyString(args["due-date"], args.dueDate, args.due_date);
4256
+ if (dueDate) changes.due_date = feedbackChange(firstNonEmptyString(item.due_date, item.due_date_text), dueDate);
4257
+
4258
+ const tags = parseFeedbackTags(args);
4259
+ if (tags) changes.tags = feedbackChange(Array.isArray(item.tags) ? item.tags : [], tags);
4260
+
4261
+ const reviewNote = firstNonEmptyString(args["review-note"], args.reviewNote, args.review_note);
4262
+ if (reviewNote) changes.review_note = feedbackChange(null, reviewNote);
4263
+ } else {
4264
+ console.error("Unknown feedback command. Use status, edit, assign, archive, get, history, validate, or apply.");
4265
+ process.exit(1);
4266
+ }
4267
+
4268
+ if (!Object.keys(changes).length) {
4269
+ console.error("No feedback changes were provided.");
4270
+ process.exit(1);
4271
+ }
4272
+
4273
+ const artifact = buildFeedbackRefinementArtifact({
4274
+ manifest,
4275
+ item,
4276
+ feedbackId,
4277
+ action,
4278
+ changes,
4279
+ reason,
4280
+ args,
4281
+ });
4282
+
4283
+ if (args["print-context"] || args.printContext || args["dry-run"] || args.dryRun) {
4284
+ if (args.json) {
4285
+ console.log(JSON.stringify(artifact, null, 2));
4286
+ } else {
4287
+ console.log(stringifyYaml(artifact));
4288
+ }
4289
+ return;
4290
+ }
4291
+
4292
+ const filePath = writeFeedbackRefinementArtifact({ paths, artifact, action });
4293
+ printFeedbackRefinementDraft({ artifact, filePath, args });
4294
+ }
4295
+
4296
+ async function runFeedbackValidateOrApply(args, mode) {
4297
+ const key = getProjectApiKey();
4298
+ if (!key) {
4299
+ console.error("Missing MYTE_API_KEY (project key) in environment/.env");
4300
+ process.exit(1);
4301
+ }
4302
+ const { absPath, payload } = readFeedbackRefinementArtifact(args);
4303
+ const feedbackId = firstNonEmptyString(args["feedback-id"], args.feedbackId, args.feedback_id, payload.feedback_id);
4304
+ if (!feedbackId) {
4305
+ console.error("Feedback refinement artifact is missing feedback_id.");
4306
+ process.exit(1);
4307
+ }
4308
+ if (args.force) payload.force = true;
4309
+
4310
+ const timeoutMs = resolveTimeoutMs(args);
4311
+ const apiBase = resolveApiBase(args);
4312
+ const clientSessionId = firstNonEmptyString(
4313
+ args["client-session-id"],
4314
+ args.clientSessionId,
4315
+ args.client_session_id,
4316
+ payload.client_session_id
4317
+ );
4318
+ const idempotencyKey = mode === "apply"
4319
+ ? resolveProjectMutationIdempotencyKey({
4320
+ args,
4321
+ operation: `feedback_refinement_apply:${feedbackId}`,
4322
+ payload,
4323
+ })
4324
+ : null;
4325
+
4326
+ let data;
4327
+ try {
4328
+ data = await postFeedbackRefinement({
4329
+ apiBase,
4330
+ key,
4331
+ timeoutMs,
4332
+ feedbackId,
4333
+ mode,
4334
+ payload,
4335
+ idempotencyKey,
4336
+ clientSessionId,
4337
+ });
4338
+ } catch (err) {
4339
+ if (args.json) {
4340
+ console.log(JSON.stringify({
4341
+ ok: false,
4342
+ status: err?.status || null,
4343
+ message: err?.message || String(err),
4344
+ data: err?.data || null,
4345
+ artifact_path: absPath,
4346
+ }, null, 2));
4347
+ } else {
4348
+ console.error(`Feedback ${mode} failed:`, err?.message || err);
4349
+ if (err?.data) console.error(JSON.stringify(err.data, null, 2));
4350
+ }
4351
+ process.exit(1);
4352
+ }
4353
+
4354
+ const summary = {
4355
+ ok: true,
4356
+ mode,
4357
+ artifact_path: absPath,
4358
+ ...summarizeFeedbackRefinementResult(data),
4359
+ };
4360
+ if (args.json) {
4361
+ console.log(JSON.stringify(summary, null, 2));
4362
+ return;
4363
+ }
4364
+ console.log(`Feedback ${mode}: ${summary.valid ? "valid" : "invalid"}`);
4365
+ console.log(`Feedback: ${summary.feedback_id || feedbackId}`);
4366
+ if (summary.history_id) console.log(`History: ${summary.history_id}`);
4367
+ if (summary.new_snapshot_hash) console.log(`New Snapshot: ${summary.new_snapshot_hash}`);
4368
+ if (summary.current_snapshot_hash) console.log(`Current Snapshot: ${summary.current_snapshot_hash}`);
4369
+ console.log(`Diffs: ${summary.diff_count}`);
4370
+ for (const diff of summary.diffs || []) {
4371
+ console.log(`- ${diff.field}: ${JSON.stringify(diff.from)} -> ${JSON.stringify(diff.to)}`);
4372
+ }
4373
+ if (mode === "apply") {
4374
+ console.log("Local feedback.yml is not modified by apply. Run `myte feedback-sync` to refresh it.");
4375
+ }
4376
+ }
4377
+
4378
+ async function runFeedbackGetOrHistory(args, mode) {
4379
+ const key = getProjectApiKey();
4380
+ if (!key) {
4381
+ console.error("Missing MYTE_API_KEY (project key) in environment/.env");
4382
+ process.exit(1);
4383
+ }
4384
+ const feedbackId = resolveFeedbackIdArg(args);
4385
+ if (!feedbackId) {
4386
+ console.error("Missing --feedback-id.");
4387
+ process.exit(1);
4388
+ }
4389
+ const timeoutMs = resolveTimeoutMs(args);
4390
+ const apiBase = resolveApiBase(args);
4391
+
4392
+ let data;
4393
+ try {
4394
+ data = mode === "history"
4395
+ ? await fetchFeedbackHistory({ apiBase, key, timeoutMs, feedbackId })
4396
+ : await fetchFeedbackReview({ apiBase, key, timeoutMs, feedbackId });
4397
+ } catch (err) {
4398
+ console.error(`Feedback ${mode} failed:`, err?.message || err);
4399
+ process.exit(1);
4400
+ }
4401
+
4402
+ if (args.json) {
4403
+ console.log(JSON.stringify(data, null, 2));
4404
+ return;
4405
+ }
4406
+ if (mode === "history") {
4407
+ console.log(`Feedback: ${data.feedback_id || feedbackId}`);
4408
+ console.log(`Events: ${data.count || 0}`);
4409
+ for (const event of data.events || []) {
4410
+ console.log(`- ${event.created_at || "unknown"} ${event.action || "refine"} ${event.history_id || ""}`);
4411
+ }
4412
+ return;
4413
+ }
4414
+ const feedback = data.feedback || {};
4415
+ console.log(`Feedback: ${feedback.feedback_id || feedbackId}`);
4416
+ console.log(`Title: ${feedback.title || "(untitled)"}`);
4417
+ console.log(`Status: ${feedback.feedback_state || feedback.status || "unknown"} (${feedback.status || "legacy unknown"})`);
4418
+ console.log(`Priority: ${feedback.priority || "n/a"}`);
4419
+ console.log(`Snapshot: ${feedback.snapshot_hash || "n/a"}`);
4420
+ }
4421
+
4422
+ async function runFeedback(args) {
4423
+ const subcommand = firstNonEmptyString(args._?.[0]) || "help";
4424
+ if (subcommand === "help") {
4425
+ printHelp();
4426
+ return;
4427
+ }
4428
+ if (["status", "edit", "assign", "archive", "refine"].includes(subcommand)) {
4429
+ await buildFeedbackDraftForCommand(args, subcommand);
4430
+ return;
4431
+ }
4432
+ if (subcommand === "validate" || subcommand === "apply") {
4433
+ await runFeedbackValidateOrApply(args, subcommand);
4434
+ return;
4435
+ }
4436
+ if (subcommand === "get" || subcommand === "history") {
4437
+ await runFeedbackGetOrHistory(args, subcommand);
4438
+ return;
4439
+ }
4440
+ console.error("Unknown feedback command. Use status, edit, assign, archive, get, history, validate, or apply.");
4441
+ process.exit(1);
4442
+ }
4443
+
3466
4444
  async function runSuggestionsSync(args) {
3467
4445
  const key = getProjectApiKey();
3468
4446
  if (!key) {
@@ -4037,30 +5015,41 @@ async function runQuery(args) {
4037
5015
  try {
4038
5016
  cfg = await fetchProjectConfig({ apiBase, key, timeoutMs });
4039
5017
  } 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;
5018
+ console.error("Failed to fetch project config for --with-diff:", err?.message || err);
5019
+ process.exit(1);
4043
5020
  }
4044
5021
 
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;
5022
+ const repoNames = Array.isArray(cfg.repo_names) ? cfg.repo_names : [];
5023
+ const repoBindings = Array.isArray(cfg.repo_bindings) ? cfg.repo_bindings : [];
5024
+ if (!repoBindings.length && !repoNames.length) {
5025
+ console.error("No project repositories are configured for --with-diff.");
5026
+ console.error("Ask the project owner or builder to configure the project repos in Myte first.");
5027
+ process.exit(1);
4061
5028
  }
4062
- if (!diffText) {
4063
- console.error("Warning: no diff context collected. Continuing without --with-diff context.");
5029
+
5030
+ const diffResult = collectGitDiffWithDiagnostics({
5031
+ projectId: cfg.project_id,
5032
+ repoNames,
5033
+ repoBindings,
5034
+ maxChars: diffLimit,
5035
+ fetchRemote,
5036
+ });
5037
+ diffText = diffResult.text;
5038
+ diffDiagnostics = diffResult.diagnostics;
5039
+ if (diffDiagnostics?.errors?.length) {
5040
+ for (const warning of diffDiagnostics.errors) console.warn(`Warning: ${warning}`);
5041
+ }
5042
+ if (!Array.isArray(diffDiagnostics?.found_repos) || !diffDiagnostics.found_repos.length) {
5043
+ console.error("No configured project repos were found locally for --with-diff.");
5044
+ const expectedBindings = Array.isArray(diffDiagnostics?.requested_repo_bindings)
5045
+ ? diffDiagnostics.requested_repo_bindings
5046
+ .map((binding) => binding.client_repo_name || binding.canonical_repo_name || binding.role)
5047
+ .filter(Boolean)
5048
+ : [];
5049
+ const expected = expectedBindings.length ? expectedBindings : repoNames;
5050
+ console.error(`Expected repo mapping or folder match for: ${expected.join(", ")}`);
5051
+ console.error("Run `myte config --json` to inspect the configured repos and local resolution.");
5052
+ process.exit(1);
4064
5053
  }
4065
5054
  }
4066
5055
 
@@ -4193,6 +5182,11 @@ async function main() {
4193
5182
  return;
4194
5183
  }
4195
5184
 
5185
+ if (command === "feedback") {
5186
+ await runFeedback(args);
5187
+ return;
5188
+ }
5189
+
4196
5190
  if (command === "suggestions") {
4197
5191
  await runSuggestions(args);
4198
5192
  return;