metheus-governance-mcp-cli 0.2.9 → 0.2.10

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 +9 -1
  2. package/cli.mjs +795 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,7 +17,7 @@ Compatibility note: legacy command alias `metheus-governance-mcp` is still suppo
17
17
  ## Install
18
18
 
19
19
  ```bash
20
- npm install -g metheus-governance-mcp-cli
20
+ npm install -g metheus-governance-mcp-cli@latest
21
21
  ```
22
22
 
23
23
  ## One command bootstrap (recommended)
@@ -61,6 +61,8 @@ Local bootstrap tools exposed by proxy:
61
61
  - `project.summary`
62
62
  - `project.describe` (alias)
63
63
  - `project.get` (alias)
64
+ - `ctxpack.merge.brief`
65
+ - `ctxpack.merge.execute`
64
66
 
65
67
  These tools accept `project_id` and return:
66
68
 
@@ -74,6 +76,12 @@ These tools accept `project_id` and return:
74
76
  - newer server version -> update local cache
75
77
  - workspace path -> follows active client workspace/root when provided by Codex/Claude
76
78
 
79
+ Ctxpack merge safety flow:
80
+ - call `ctxpack.merge.brief` first
81
+ - review who changed what (`created_by`, `changed_paths`, metadata summary)
82
+ - get explicit owner decision in chat
83
+ - then call `ctxpack.merge.execute` with `owner_confirmation`
84
+
77
85
  Manual ctxpack pull/update:
78
86
 
79
87
  ```bash
package/cli.mjs CHANGED
@@ -140,11 +140,41 @@ function firstNonEmptyString(values) {
140
140
  return "";
141
141
  }
142
142
 
143
+ function extractWorkspaceCandidateFromFolders(rawFolders) {
144
+ if (!Array.isArray(rawFolders)) return "";
145
+ for (const folder of rawFolders) {
146
+ if (typeof folder === "string") {
147
+ const direct = firstNonEmptyString([fileURIToLocalPath(folder), folder]);
148
+ if (direct) return resolveWorkspaceDir(direct);
149
+ continue;
150
+ }
151
+ if (!folder || typeof folder !== "object" || Array.isArray(folder)) continue;
152
+ const uriValue = firstNonEmptyString([
153
+ folder.uri,
154
+ folder.root_uri,
155
+ folder.rootUri,
156
+ folder.path,
157
+ folder.root_path,
158
+ folder.rootPath,
159
+ ]);
160
+ const candidate = firstNonEmptyString([fileURIToLocalPath(uriValue), uriValue]);
161
+ if (candidate) return resolveWorkspaceDir(candidate);
162
+ }
163
+ return "";
164
+ }
165
+
143
166
  function extractWorkspaceCandidateFromRequest(requestObj, toolArgs) {
144
167
  const params = safeObject(requestObj?.params);
145
168
  const meta = safeObject(params._meta);
169
+ const workspaceFromFolders = firstNonEmptyString([
170
+ extractWorkspaceCandidateFromFolders(params.workspace_folders),
171
+ extractWorkspaceCandidateFromFolders(params.workspaceFolders),
172
+ extractWorkspaceCandidateFromFolders(meta.workspace_folders),
173
+ extractWorkspaceCandidateFromFolders(meta.workspaceFolders),
174
+ ]);
146
175
  const args = safeObject(toolArgs);
147
176
  const rawCandidate = firstNonEmptyString([
177
+ workspaceFromFolders,
148
178
  args.workspace_dir,
149
179
  args.workspaceDir,
150
180
  params.workspace_dir,
@@ -172,10 +202,12 @@ function extractWorkspaceCandidateFromEnv() {
172
202
  const rawCandidate = firstNonEmptyString([
173
203
  process.env.METHEUS_WORKSPACE_DIR,
174
204
  process.env.CLAUDE_WORKSPACE_DIR,
205
+ process.env.CLAUDE_WORKSPACE_URI,
175
206
  process.env.CLAUDE_PROJECT_DIR,
176
207
  process.env.CODEX_WORKSPACE_DIR,
177
208
  process.env.WORKSPACE_DIR,
178
209
  process.env.WORKSPACE_FOLDER,
210
+ process.env.VSCODE_WORKSPACE_FOLDER,
179
211
  process.env.VSCODE_CWD,
180
212
  process.env.PWD,
181
213
  process.env.INIT_CWD,
@@ -198,6 +230,25 @@ function resolveWorkspaceDirForRequest(defaultWorkspaceDir, requestObj, toolArgs
198
230
  return resolveWorkspaceDir(candidate);
199
231
  }
200
232
 
233
+ function resolveProjectIDForRequest({
234
+ toolArgs,
235
+ args,
236
+ workspaceDir,
237
+ responseProjectID,
238
+ envelopeProjectID,
239
+ }) {
240
+ const resolvedWorkspaceDir = resolveWorkspaceDir(workspaceDir || process.cwd());
241
+ const workspaceMeta = loadWorkspaceMeta(resolvedWorkspaceDir);
242
+ return firstNonEmptyString([
243
+ toolArgs?.project_id,
244
+ toolArgs?.projectID,
245
+ responseProjectID,
246
+ envelopeProjectID,
247
+ workspaceMeta.project_id,
248
+ args?.projectID,
249
+ ]);
250
+ }
251
+
201
252
  function homeCtxpackCacheRootDir() {
202
253
  const home = String(process.env.USERPROFILE || process.env.HOME || "").trim();
203
254
  if (!home) {
@@ -1846,7 +1897,10 @@ function postJSON(urlText, timeoutSeconds, token, payload) {
1846
1897
  resolve(text.trim());
1847
1898
  return;
1848
1899
  }
1849
- reject(new Error(text.trim() || `http ${statusCode}`));
1900
+ const err = new Error(text.trim() || `http ${statusCode}`);
1901
+ err.statusCode = statusCode;
1902
+ err.responseBody = text;
1903
+ reject(err);
1850
1904
  });
1851
1905
  },
1852
1906
  );
@@ -1932,6 +1986,7 @@ function jsonRpcResult(requestObj, result) {
1932
1986
  }
1933
1987
 
1934
1988
  const LOCAL_PROJECT_TOOL_NAMES = ["project.summary", "project.describe", "project.get"];
1989
+ const LOCAL_CTXPACK_MERGE_TOOL_NAMES = ["ctxpack.merge.brief", "ctxpack.merge.execute"];
1935
1990
 
1936
1991
  function buildProjectSummaryInputSchema() {
1937
1992
  return {
@@ -1955,6 +2010,62 @@ function buildProjectSummaryInputSchema() {
1955
2010
  };
1956
2011
  }
1957
2012
 
2013
+ function buildCtxpackMergeBriefInputSchema() {
2014
+ return {
2015
+ type: "object",
2016
+ properties: {
2017
+ project_id: {
2018
+ type: "string",
2019
+ description: "Project UUID. If omitted, current configured project_id is used.",
2020
+ },
2021
+ status: {
2022
+ type: "string",
2023
+ description: "Merge-request status filter. Default: open.",
2024
+ enum: ["open", "review", "approved", "rejected", "merged", "closed", "all"],
2025
+ },
2026
+ limit: {
2027
+ type: "integer",
2028
+ minimum: 1,
2029
+ maximum: 100,
2030
+ description: "Maximum merge requests to inspect. Default: 20.",
2031
+ },
2032
+ },
2033
+ additionalProperties: false,
2034
+ };
2035
+ }
2036
+
2037
+ function buildCtxpackMergeExecuteInputSchema() {
2038
+ return {
2039
+ type: "object",
2040
+ properties: {
2041
+ project_id: {
2042
+ type: "string",
2043
+ description: "Project UUID. If omitted, current configured project_id is used.",
2044
+ },
2045
+ merge_request_id: {
2046
+ type: "string",
2047
+ description: "Target merge request UUID.",
2048
+ },
2049
+ action: {
2050
+ type: "string",
2051
+ description: "Action to execute.",
2052
+ enum: ["review", "approve", "reject", "close", "merge"],
2053
+ },
2054
+ owner_confirmation: {
2055
+ type: "string",
2056
+ description:
2057
+ "Explicit owner confirmation text from chat (for safety). Example: confirm / approved / yes.",
2058
+ },
2059
+ note: {
2060
+ type: "string",
2061
+ description: "Optional reviewer note recorded in metadata for non-merge actions.",
2062
+ },
2063
+ },
2064
+ required: ["merge_request_id", "action", "owner_confirmation"],
2065
+ additionalProperties: false,
2066
+ };
2067
+ }
2068
+
1958
2069
  function buildLocalToolSpecs() {
1959
2070
  return [
1960
2071
  {
@@ -1975,6 +2086,18 @@ function buildLocalToolSpecs() {
1975
2086
  "Alias of project.summary. Returns project metadata and access status for the given project_id.",
1976
2087
  inputSchema: buildProjectSummaryInputSchema(),
1977
2088
  },
2089
+ {
2090
+ name: "ctxpack.merge.brief",
2091
+ description:
2092
+ "Before merge, summarize pending ctxpack merge requests (who changed what), show risk/recommendation, and provide owner confirmation checklist.",
2093
+ inputSchema: buildCtxpackMergeBriefInputSchema(),
2094
+ },
2095
+ {
2096
+ name: "ctxpack.merge.execute",
2097
+ description:
2098
+ "Execute ctxpack merge-request action (review/approve/reject/close/merge) after explicit owner confirmation.",
2099
+ inputSchema: buildCtxpackMergeExecuteInputSchema(),
2100
+ },
1978
2101
  ];
1979
2102
  }
1980
2103
 
@@ -2278,6 +2401,10 @@ async function loadProjectSummaryForTool({
2278
2401
  visibility: String(project.visibility || "").trim() || "private",
2279
2402
  template: String(project.template || "").trim() || "blank",
2280
2403
  template_label: normalizeTemplateLabel(project.template),
2404
+ owner_user_id: String(project.owner_user_id || "").trim(),
2405
+ program_owner_user_id: String(project.program_owner_user_id || "").trim(),
2406
+ tech_owner_user_id: String(project.tech_owner_user_id || "").trim(),
2407
+ governance_owner_user_id: String(project.governance_owner_user_id || "").trim(),
2281
2408
  owner_name: String(project.owner_name || "").trim(),
2282
2409
  program_owner_name: String(project.program_owner_name || "").trim(),
2283
2410
  tech_owner_name: String(project.tech_owner_name || "").trim(),
@@ -2300,6 +2427,522 @@ async function loadProjectSummaryForTool({
2300
2427
  };
2301
2428
  }
2302
2429
 
2430
+ function normalizeMergeStatusFilter(rawValue) {
2431
+ const value = String(rawValue || "").trim().toLowerCase();
2432
+ if (!value) return "open";
2433
+ if (["open", "review", "approved", "rejected", "merged", "closed", "all"].includes(value)) {
2434
+ return value;
2435
+ }
2436
+ return "";
2437
+ }
2438
+
2439
+ function normalizeMergeAction(rawValue) {
2440
+ const value = String(rawValue || "").trim().toLowerCase();
2441
+ if (["review", "approve", "reject", "close", "merge"].includes(value)) {
2442
+ return value;
2443
+ }
2444
+ return "";
2445
+ }
2446
+
2447
+ function parseMergeMetadata(rawMetadata) {
2448
+ if (!rawMetadata) return {};
2449
+ if (typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) {
2450
+ return rawMetadata;
2451
+ }
2452
+ if (typeof rawMetadata !== "string") return {};
2453
+ try {
2454
+ const parsed = JSON.parse(rawMetadata);
2455
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
2456
+ return parsed;
2457
+ } catch {
2458
+ return {};
2459
+ }
2460
+ }
2461
+
2462
+ function collectMergeChangedPaths(metadataObj) {
2463
+ const meta = safeObject(metadataObj);
2464
+ const out = [];
2465
+ const pushPath = (raw) => {
2466
+ const value = String(raw || "").trim();
2467
+ if (!value) return;
2468
+ if (!out.includes(value)) out.push(value);
2469
+ };
2470
+ for (const pathValue of ensureArray(meta.changed_paths)) {
2471
+ pushPath(pathValue);
2472
+ }
2473
+ for (const pathValue of ensureArray(meta.conflict_paths)) {
2474
+ pushPath(pathValue);
2475
+ }
2476
+ const files = ensureArray(meta.files);
2477
+ for (const file of files) {
2478
+ pushPath(safeObject(file).path);
2479
+ }
2480
+ const changeSet = safeObject(meta.change_set);
2481
+ const changeSetFiles = ensureArray(changeSet.files);
2482
+ for (const file of changeSetFiles) {
2483
+ pushPath(safeObject(file).path);
2484
+ }
2485
+ return out;
2486
+ }
2487
+
2488
+ function summarizeMergeMetadata(metadataObj) {
2489
+ const meta = safeObject(metadataObj);
2490
+ const parts = [];
2491
+ const changeSet = safeObject(meta.change_set);
2492
+ const summary = safeObject(changeSet.summary);
2493
+ const changedCount = Number.parseInt(String(summary.changed || 0), 10);
2494
+ if (Number.isFinite(changedCount) && changedCount > 0) {
2495
+ parts.push(`changed files: ${changedCount}`);
2496
+ }
2497
+ const title = String(meta.title || meta.summary || meta.reason || "").trim();
2498
+ if (title) {
2499
+ parts.push(title);
2500
+ }
2501
+ if (!parts.length) return "";
2502
+ return parts.join(" | ");
2503
+ }
2504
+
2505
+ function parseISOTime(rawValue) {
2506
+ const text = String(rawValue || "").trim();
2507
+ if (!text) return 0;
2508
+ const ms = Date.parse(text);
2509
+ if (!Number.isFinite(ms)) return 0;
2510
+ return ms;
2511
+ }
2512
+
2513
+ function extractActorFromToken(token) {
2514
+ const payload = safeObject(decodeJwtPayload(token));
2515
+ const userID = firstNonEmptyString([payload.sub, payload.user_id, payload.uid]);
2516
+ const email = firstNonEmptyString([payload.email, payload.preferred_username]);
2517
+ const name = firstNonEmptyString([payload.name, payload.given_name]);
2518
+ return { user_id: userID, email, name };
2519
+ }
2520
+
2521
+ async function loadProjectMemberMap({ siteBaseURL, projectID, token, timeoutSeconds }) {
2522
+ const encodedProjectID = encodeURIComponent(projectID);
2523
+ try {
2524
+ const raw = await getJSONWithAuth(
2525
+ `${siteBaseURL}/api/v1/projects/${encodedProjectID}/members?limit=500&offset=0`,
2526
+ timeoutSeconds,
2527
+ token,
2528
+ );
2529
+ const members = ensureArray(raw);
2530
+ const map = new Map();
2531
+ for (const rowRaw of members) {
2532
+ const row = safeObject(rowRaw);
2533
+ const userID = String(row.user_id || "").trim();
2534
+ if (!userID) continue;
2535
+ map.set(userID, {
2536
+ user_id: userID,
2537
+ name: String(row.name || "").trim(),
2538
+ email: String(row.email || "").trim(),
2539
+ role: String(row.role || "").trim(),
2540
+ });
2541
+ }
2542
+ return map;
2543
+ } catch {
2544
+ return new Map();
2545
+ }
2546
+ }
2547
+
2548
+ async function loadOrgOwnerUserID({ siteBaseURL, orgID, token, timeoutSeconds }) {
2549
+ const normalizedOrgID = String(orgID || "").trim();
2550
+ if (!normalizedOrgID) return "";
2551
+ try {
2552
+ const raw = await getJSONWithAuth(
2553
+ `${siteBaseURL}/api/v1/orgs/${encodeURIComponent(normalizedOrgID)}`,
2554
+ timeoutSeconds,
2555
+ token,
2556
+ );
2557
+ return String(safeObject(raw).owner_user_id || "").trim();
2558
+ } catch {
2559
+ return "";
2560
+ }
2561
+ }
2562
+
2563
+ function resolveActorDisplay(userID, membersByUserID) {
2564
+ const id = String(userID || "").trim();
2565
+ if (!id) return "-";
2566
+ const row = membersByUserID.get(id);
2567
+ if (!row) return id;
2568
+ const name = String(row.name || "").trim();
2569
+ const email = String(row.email || "").trim();
2570
+ if (name && email) return `${name} <${email}>`;
2571
+ if (name) return name;
2572
+ if (email) return email;
2573
+ return id;
2574
+ }
2575
+
2576
+ function isOwnerConfirmationAccepted(rawValue) {
2577
+ const text = String(rawValue || "").trim().toLowerCase();
2578
+ if (!text) return false;
2579
+ return ["yes", "y", "ok", "approve", "approved", "confirm", "confirmed"].includes(text);
2580
+ }
2581
+
2582
+ async function loadCtxpackMergeBriefForTool({
2583
+ siteBaseURL,
2584
+ projectID,
2585
+ token,
2586
+ timeoutSeconds,
2587
+ statusFilter,
2588
+ limit,
2589
+ workspaceDir,
2590
+ }) {
2591
+ const summary = await loadProjectSummaryForTool({
2592
+ siteBaseURL,
2593
+ projectID,
2594
+ token,
2595
+ timeoutSeconds,
2596
+ includeCtxpack: false,
2597
+ syncCtxpackLocal: false,
2598
+ workspaceDir,
2599
+ });
2600
+ if (String(summary.access || "") !== "granted") {
2601
+ return {
2602
+ project_id: projectID,
2603
+ access: summary.access || "error",
2604
+ status_code: summary.status_code || 500,
2605
+ message: summary.message || "Unable to access project.",
2606
+ merge_requests: [],
2607
+ recommendation: {},
2608
+ };
2609
+ }
2610
+
2611
+ const normalizedStatus = normalizeMergeStatusFilter(statusFilter) || "open";
2612
+ const cappedLimit = Math.max(1, Math.min(100, Number.parseInt(String(limit || 20), 10) || 20));
2613
+ const encodedProjectID = encodeURIComponent(projectID);
2614
+ const statusParam =
2615
+ normalizedStatus === "all" || normalizedStatus === "open"
2616
+ ? ""
2617
+ : `&status=${encodeURIComponent(normalizedStatus)}`;
2618
+ const listURL = `${siteBaseURL}/api/v1/projects/${encodedProjectID}/ctxpack/merge-requests?limit=${cappedLimit}&offset=0${statusParam}`;
2619
+ let mergeFeatureAvailable = true;
2620
+ let mergeFeatureWarning = "";
2621
+ let allItems = [];
2622
+ try {
2623
+ const rawList = await getJSONWithAuth(listURL, timeoutSeconds, token);
2624
+ allItems = ensureArray(rawList);
2625
+ } catch (err) {
2626
+ const statusCode = Number(err?.statusCode || 0) || 500;
2627
+ if (statusCode === 404) {
2628
+ mergeFeatureAvailable = false;
2629
+ mergeFeatureWarning = "Merge-request API is not enabled on this server yet.";
2630
+ allItems = [];
2631
+ } else {
2632
+ throw err;
2633
+ }
2634
+ }
2635
+ const statusOpenSet = new Set(["open", "review", "approved"]);
2636
+ const filteredItems =
2637
+ normalizedStatus === "open"
2638
+ ? allItems.filter((rowRaw) => statusOpenSet.has(String(safeObject(rowRaw).status || "").trim().toLowerCase()))
2639
+ : allItems;
2640
+
2641
+ const membersByUserID = await loadProjectMemberMap({
2642
+ siteBaseURL,
2643
+ projectID,
2644
+ token,
2645
+ timeoutSeconds,
2646
+ });
2647
+
2648
+ const actor = extractActorFromToken(token);
2649
+ const orgOwnerUserID = await loadOrgOwnerUserID({
2650
+ siteBaseURL,
2651
+ orgID: summary.org_id,
2652
+ token,
2653
+ timeoutSeconds,
2654
+ });
2655
+ const projectOwnerIDs = new Set(
2656
+ [summary.owner_user_id, summary.program_owner_user_id, summary.tech_owner_user_id, summary.governance_owner_user_id]
2657
+ .map((value) => String(value || "").trim())
2658
+ .filter(Boolean),
2659
+ );
2660
+ const isProjectOwner = actor.user_id ? projectOwnerIDs.has(actor.user_id) : false;
2661
+ const isOrgOwner = Boolean(actor.user_id && orgOwnerUserID && actor.user_id === orgOwnerUserID);
2662
+ const ownerGate = isProjectOwner || isOrgOwner;
2663
+
2664
+ const normalizedItems = filteredItems
2665
+ .map((rowRaw) => {
2666
+ const row = safeObject(rowRaw);
2667
+ const metadata = parseMergeMetadata(row.metadata);
2668
+ const changedPaths = collectMergeChangedPaths(metadata);
2669
+ const status = String(row.status || "").trim().toLowerCase();
2670
+ return {
2671
+ merge_request_id: String(row.merge_request_id || "").trim(),
2672
+ status,
2673
+ created_by_user_id: String(row.created_by || "").trim(),
2674
+ created_by: resolveActorDisplay(String(row.created_by || "").trim(), membersByUserID),
2675
+ reviewed_by_user_id: String(row.reviewed_by || "").trim(),
2676
+ reviewed_by: resolveActorDisplay(String(row.reviewed_by || "").trim(), membersByUserID),
2677
+ approved_at: String(row.approved_at || "").trim(),
2678
+ created_at: String(row.created_at || "").trim(),
2679
+ updated_at: String(row.updated_at || "").trim(),
2680
+ source_version_id: String(row.source_version_id || "").trim(),
2681
+ target_version_id: String(row.target_version_id || "").trim(),
2682
+ metadata_summary: summarizeMergeMetadata(metadata),
2683
+ changed_paths: changedPaths,
2684
+ changed_paths_count: changedPaths.length,
2685
+ };
2686
+ })
2687
+ .sort((a, b) => parseISOTime(b.updated_at) - parseISOTime(a.updated_at));
2688
+
2689
+ const statusCount = {
2690
+ open: normalizedItems.filter((row) => row.status === "open").length,
2691
+ review: normalizedItems.filter((row) => row.status === "review").length,
2692
+ approved: normalizedItems.filter((row) => row.status === "approved").length,
2693
+ rejected: normalizedItems.filter((row) => row.status === "rejected").length,
2694
+ merged: normalizedItems.filter((row) => row.status === "merged").length,
2695
+ closed: normalizedItems.filter((row) => row.status === "closed").length,
2696
+ };
2697
+
2698
+ const approvedCandidates = normalizedItems.filter((row) => row.status === "approved");
2699
+ const reviewCandidates = normalizedItems.filter((row) => row.status === "review" || row.status === "open");
2700
+ let recommendationAction = "no_action";
2701
+ let recommendationReason = "No pending merge requests.";
2702
+ let ownerPrompt = "";
2703
+ if (!mergeFeatureAvailable) {
2704
+ recommendationAction = "merge_api_unavailable";
2705
+ recommendationReason = mergeFeatureWarning;
2706
+ ownerPrompt =
2707
+ "Ask platform owner/admin to enable ctxpack merge-request API before merge approval workflow.";
2708
+ } else if (approvedCandidates.length > 0) {
2709
+ recommendationAction = ownerGate ? "merge_ready" : "owner_approval_required";
2710
+ recommendationReason =
2711
+ approvedCandidates.length === 1
2712
+ ? "There is 1 approved merge request ready for merge."
2713
+ : `There are ${approvedCandidates.length} approved merge requests ready for merge.`;
2714
+ const ids = approvedCandidates.slice(0, 5).map((row) => row.merge_request_id).filter(Boolean);
2715
+ ownerPrompt = `Ask owner decision: merge now for ${ids.join(", ")} ?`;
2716
+ } else if (reviewCandidates.length > 0) {
2717
+ recommendationAction = ownerGate ? "review_then_approve" : "owner_review_required";
2718
+ recommendationReason =
2719
+ reviewCandidates.length === 1
2720
+ ? "There is 1 merge request waiting for review/approval."
2721
+ : `There are ${reviewCandidates.length} merge requests waiting for review/approval.`;
2722
+ const ids = reviewCandidates.slice(0, 5).map((row) => row.merge_request_id).filter(Boolean);
2723
+ ownerPrompt = `Ask owner decision: approve or reject ${ids.join(", ")} ?`;
2724
+ }
2725
+
2726
+ return {
2727
+ project_id: projectID,
2728
+ access: "granted",
2729
+ status_code: 200,
2730
+ message: mergeFeatureAvailable
2731
+ ? "Ctxpack merge briefing is ready."
2732
+ : "Ctxpack merge briefing is ready, but merge-request API is unavailable on this server.",
2733
+ merge_feature_available: mergeFeatureAvailable,
2734
+ merge_feature_warning: mergeFeatureWarning,
2735
+ owner_gate: {
2736
+ actor_user_id: actor.user_id,
2737
+ actor_email: actor.email,
2738
+ actor_name: actor.name,
2739
+ project_owner_user_id: String(summary.owner_user_id || "").trim(),
2740
+ org_owner_user_id: orgOwnerUserID,
2741
+ is_project_owner: isProjectOwner,
2742
+ is_org_owner: isOrgOwner,
2743
+ can_owner_finalize: ownerGate,
2744
+ },
2745
+ status_filter: normalizedStatus,
2746
+ merge_request_count: normalizedItems.length,
2747
+ status_count: statusCount,
2748
+ merge_requests: normalizedItems,
2749
+ recommendation: {
2750
+ action: recommendationAction,
2751
+ reason: recommendationReason,
2752
+ owner_prompt: ownerPrompt,
2753
+ owner_confirmation_required: true,
2754
+ },
2755
+ };
2756
+ }
2757
+
2758
+ function buildCtxpackMergeBriefText(brief) {
2759
+ if (String(brief?.access || "") !== "granted") {
2760
+ return [
2761
+ `Project ID: ${brief?.project_id || "-"}`,
2762
+ `Access: ${brief?.access || "error"}`,
2763
+ `Status: ${brief?.status_code || "-"}`,
2764
+ `${brief?.message || "Unable to load ctxpack merge briefing."}`,
2765
+ ].join("\n");
2766
+ }
2767
+
2768
+ const lines = [
2769
+ `Project ID: ${brief.project_id || "-"}`,
2770
+ `Merge Requests: ${Number(brief.merge_request_count || 0)}`,
2771
+ `Status Filter: ${brief.status_filter || "open"}`,
2772
+ `Owner Gate: ${brief.owner_gate?.can_owner_finalize ? "owner-confirmation possible" : "owner token required"}`,
2773
+ "",
2774
+ "Status Summary:",
2775
+ `- Open: ${Number(brief.status_count?.open || 0)}`,
2776
+ `- Review: ${Number(brief.status_count?.review || 0)}`,
2777
+ `- Approved: ${Number(brief.status_count?.approved || 0)}`,
2778
+ `- Rejected: ${Number(brief.status_count?.rejected || 0)}`,
2779
+ `- Merged: ${Number(brief.status_count?.merged || 0)}`,
2780
+ `- Closed: ${Number(brief.status_count?.closed || 0)}`,
2781
+ ];
2782
+ if (brief.merge_feature_available === false) {
2783
+ lines.push("");
2784
+ lines.push("Merge API:");
2785
+ lines.push(`- ${brief.merge_feature_warning || "Merge-request API is unavailable on this server."}`);
2786
+ }
2787
+ lines.push("");
2788
+ lines.push("Recommendation:");
2789
+ lines.push(`- Action: ${brief.recommendation?.action || "no_action"}`);
2790
+ lines.push(`- Reason: ${brief.recommendation?.reason || "-"}`);
2791
+ const ownerPrompt = String(brief.recommendation?.owner_prompt || "").trim();
2792
+ if (ownerPrompt) {
2793
+ lines.push(`- Owner Prompt: ${ownerPrompt}`);
2794
+ }
2795
+ const requests = ensureArray(brief.merge_requests);
2796
+ if (requests.length > 0) {
2797
+ lines.push("");
2798
+ lines.push("Pending Details:");
2799
+ for (const row of requests.slice(0, 20)) {
2800
+ const changedPreview =
2801
+ row.changed_paths_count > 0 ? `${row.changed_paths_count} path(s)` : "no changed_paths metadata";
2802
+ const changedList =
2803
+ row.changed_paths_count > 0 ? ` [${ensureArray(row.changed_paths).slice(0, 5).join(", ")}]` : "";
2804
+ lines.push(
2805
+ `- ${row.merge_request_id} | ${row.status} | by ${row.created_by || row.created_by_user_id || "-"} | ${changedPreview}${changedList}`,
2806
+ );
2807
+ if (row.metadata_summary) {
2808
+ lines.push(` summary: ${row.metadata_summary}`);
2809
+ }
2810
+ }
2811
+ }
2812
+ lines.push("");
2813
+ lines.push(
2814
+ "Before execute, ask owner for explicit confirmation, then run ctxpack.merge.execute with owner_confirmation.",
2815
+ );
2816
+ return lines.join("\n");
2817
+ }
2818
+
2819
+ async function executeCtxpackMergeActionForTool({
2820
+ siteBaseURL,
2821
+ projectID,
2822
+ mergeRequestID,
2823
+ action,
2824
+ ownerConfirmation,
2825
+ token,
2826
+ timeoutSeconds,
2827
+ workspaceDir,
2828
+ }) {
2829
+ if (!isOwnerConfirmationAccepted(ownerConfirmation)) {
2830
+ return {
2831
+ project_id: projectID,
2832
+ merge_request_id: mergeRequestID,
2833
+ action,
2834
+ ok: false,
2835
+ status_code: 400,
2836
+ message: "Owner confirmation missing. Ask owner and pass owner_confirmation (confirm/approved/yes).",
2837
+ };
2838
+ }
2839
+
2840
+ const summary = await loadProjectSummaryForTool({
2841
+ siteBaseURL,
2842
+ projectID,
2843
+ token,
2844
+ timeoutSeconds,
2845
+ includeCtxpack: false,
2846
+ syncCtxpackLocal: false,
2847
+ workspaceDir,
2848
+ });
2849
+ if (String(summary.access || "") !== "granted") {
2850
+ return {
2851
+ project_id: projectID,
2852
+ merge_request_id: mergeRequestID,
2853
+ action,
2854
+ ok: false,
2855
+ status_code: summary.status_code || 500,
2856
+ message: summary.message || "Project access denied.",
2857
+ };
2858
+ }
2859
+
2860
+ const actor = extractActorFromToken(token);
2861
+ const orgOwnerUserID = await loadOrgOwnerUserID({
2862
+ siteBaseURL,
2863
+ orgID: summary.org_id,
2864
+ token,
2865
+ timeoutSeconds,
2866
+ });
2867
+ const projectOwnerIDs = new Set(
2868
+ [summary.owner_user_id, summary.program_owner_user_id, summary.tech_owner_user_id, summary.governance_owner_user_id]
2869
+ .map((value) => String(value || "").trim())
2870
+ .filter(Boolean),
2871
+ );
2872
+ const isProjectOwner = actor.user_id ? projectOwnerIDs.has(actor.user_id) : false;
2873
+ const isOrgOwner = Boolean(actor.user_id && orgOwnerUserID && actor.user_id === orgOwnerUserID);
2874
+ if (!(isProjectOwner || isOrgOwner)) {
2875
+ return {
2876
+ project_id: projectID,
2877
+ merge_request_id: mergeRequestID,
2878
+ action,
2879
+ ok: false,
2880
+ status_code: 403,
2881
+ message: "Owner gate blocked: current token is not project/org owner. Ask owner account to execute.",
2882
+ };
2883
+ }
2884
+
2885
+ const encodedProjectID = encodeURIComponent(projectID);
2886
+ const encodedMRID = encodeURIComponent(mergeRequestID);
2887
+ const endpoint = `${siteBaseURL}/api/v1/projects/${encodedProjectID}/ctxpack/merge-requests/${encodedMRID}/${action}`;
2888
+
2889
+ try {
2890
+ const responseText = await postJSON(endpoint, timeoutSeconds, token, {});
2891
+ const parsed = tryJsonParse(responseText) || {};
2892
+ return {
2893
+ project_id: projectID,
2894
+ merge_request_id: mergeRequestID,
2895
+ action,
2896
+ ok: true,
2897
+ status_code: 200,
2898
+ message: `Merge request action '${action}' completed.`,
2899
+ actor_user_id: actor.user_id,
2900
+ actor_email: actor.email,
2901
+ response: safeObject(parsed),
2902
+ };
2903
+ } catch (err) {
2904
+ const statusCode = Number(err?.statusCode || 0) || 500;
2905
+ const bodyText = String(err?.responseBody || err?.message || "").trim();
2906
+ let message = bodyText || `Failed to execute action '${action}'.`;
2907
+ if (statusCode === 403) {
2908
+ message = "Forbidden by server policy/role. Current account cannot execute this action.";
2909
+ } else if (statusCode === 409) {
2910
+ message =
2911
+ "Merge conflict or approval state mismatch. Refresh briefing (ctxpack.merge.brief), then re-review before merge.";
2912
+ } else if (statusCode === 404) {
2913
+ message = "Merge request not found for this project.";
2914
+ }
2915
+ return {
2916
+ project_id: projectID,
2917
+ merge_request_id: mergeRequestID,
2918
+ action,
2919
+ ok: false,
2920
+ status_code: statusCode,
2921
+ message,
2922
+ error: bodyText,
2923
+ };
2924
+ }
2925
+ }
2926
+
2927
+ function buildCtxpackMergeExecuteText(result) {
2928
+ const lines = [
2929
+ `Project ID: ${result.project_id || "-"}`,
2930
+ `Merge Request ID: ${result.merge_request_id || "-"}`,
2931
+ `Action: ${result.action || "-"}`,
2932
+ `Result: ${result.ok ? "ok" : "failed"} (status ${result.status_code || "-"})`,
2933
+ `Message: ${result.message || "-"}`,
2934
+ ];
2935
+ if (result.ok) {
2936
+ const mergedVersionID = String(result.response?.merged_version_id || "").trim();
2937
+ if (mergedVersionID) {
2938
+ lines.push(`Merged Version ID: ${mergedVersionID}`);
2939
+ }
2940
+ } else if (result.error) {
2941
+ lines.push(`Server: ${result.error}`);
2942
+ }
2943
+ return lines.join("\n");
2944
+ }
2945
+
2303
2946
  function buildProjectSummaryText(summary) {
2304
2947
  if (String(summary?.access || "") !== "granted") {
2305
2948
  return [
@@ -2372,6 +3015,7 @@ function appendProjectHintToInitialize(responseObj, args) {
2372
3015
  "- MUST call `project.summary` first when the user provides only a Project ID or asks project overview/agenda.",
2373
3016
  "- `project.describe` and `project.get` are aliases of `project.summary`.",
2374
3017
  "- After project summary, use workitem/evidence/decision tools as follow-up.",
3018
+ "- Before any ctxpack merge, call `ctxpack.merge.brief` first, share recommendation to owner, get explicit owner confirmation, then call `ctxpack.merge.execute`.",
2375
3019
  ];
2376
3020
  if (args.projectID) {
2377
3021
  hintLines.splice(1, 0, `- Default project_id is ${args.projectID}.`);
@@ -2393,14 +3037,14 @@ async function maybeAutoSyncCtxpackForCall({
2393
3037
  }) {
2394
3038
  if (!isJsonRpcMethod(requestObj, "tools/call")) return null;
2395
3039
  if (!token) return null;
2396
- if (LOCAL_PROJECT_TOOL_NAMES.includes(toolName)) return null;
3040
+ if (LOCAL_PROJECT_TOOL_NAMES.includes(toolName) || LOCAL_CTXPACK_MERGE_TOOL_NAMES.includes(toolName)) return null;
2397
3041
  if (String(toolName || "").trim().toLowerCase() === "ctxpack.ensure") return null;
2398
3042
 
2399
- const projectID = firstNonEmptyString([
2400
- toolArgs?.project_id,
2401
- toolArgs?.projectID,
2402
- args.projectID,
2403
- ]);
3043
+ const projectID = resolveProjectIDForRequest({
3044
+ toolArgs,
3045
+ args,
3046
+ workspaceDir,
3047
+ });
2404
3048
  if (!isUUID(projectID)) return null;
2405
3049
 
2406
3050
  const workspacePath = resolveWorkspaceDir(workspaceDir || process.cwd());
@@ -2493,7 +3137,12 @@ async function appendWorkitemListHints(responseObj, args, toolArgs, token) {
2493
3137
  }
2494
3138
 
2495
3139
  const projectID = String(
2496
- toolArgs?.project_id || toolArgs?.projectID || responseProjectID || args.projectID || "",
3140
+ resolveProjectIDForRequest({
3141
+ toolArgs,
3142
+ args,
3143
+ workspaceDir: args.workspaceDir,
3144
+ responseProjectID,
3145
+ }),
2497
3146
  ).trim();
2498
3147
 
2499
3148
  if (projectID && isUUID(projectID)) {
@@ -2562,7 +3211,12 @@ function appendCtxpackEnsureSyncHints(responseObj, args, toolArgs, requestObj) {
2562
3211
  if (!Object.keys(body).length) return responseObj;
2563
3212
 
2564
3213
  const projectID = String(
2565
- toolArgs?.project_id || toolArgs?.projectID || envelope.project_id || args.projectID || "",
3214
+ resolveProjectIDForRequest({
3215
+ toolArgs,
3216
+ args,
3217
+ workspaceDir: args.workspaceDir,
3218
+ envelopeProjectID: envelope.project_id,
3219
+ }),
2566
3220
  ).trim();
2567
3221
  if (!projectID || !isUUID(projectID)) return responseObj;
2568
3222
  const workspaceDir = resolveWorkspaceDirForRequest(args.workspaceDir || process.cwd(), requestObj, toolArgs);
@@ -2677,13 +3331,15 @@ async function injectCtxpackPreflightToken(requestObj, toolName, toolArgs, args,
2677
3331
  return requestObj;
2678
3332
  }
2679
3333
 
2680
- const projectID = firstNonEmptyString([
2681
- rawArgs.project_id,
2682
- rawArgs.projectID,
2683
- toolArgs?.project_id,
2684
- toolArgs?.projectID,
2685
- args.projectID,
2686
- ]);
3334
+ const mergedToolArgs = {
3335
+ ...safeObject(toolArgs),
3336
+ project_id: firstNonEmptyString([rawArgs.project_id, rawArgs.projectID, toolArgs?.project_id, toolArgs?.projectID]),
3337
+ };
3338
+ const projectID = resolveProjectIDForRequest({
3339
+ toolArgs: mergedToolArgs,
3340
+ args,
3341
+ workspaceDir,
3342
+ });
2687
3343
  if (!isUUID(projectID)) {
2688
3344
  return requestObj;
2689
3345
  }
@@ -2773,12 +3429,13 @@ async function appendCtxpackConflictHintToErrorResponse(responseObj, args, toolN
2773
3429
  }
2774
3430
 
2775
3431
  async function runProxy(flags) {
2776
- const workspaceMeta = loadWorkspaceMeta(process.cwd());
3432
+ const explicitWorkspaceDirRaw = String(flags["workspace-dir"] || "").trim();
3433
+ const explicitWorkspaceDir = explicitWorkspaceDirRaw ? resolveWorkspaceDir(explicitWorkspaceDirRaw) : "";
2777
3434
  const args = {
2778
3435
  baseURL: flags["base-url"] || DEFAULT_BASE_URL,
2779
- projectID: String(flags["project-id"] || workspaceMeta.project_id || "").trim(),
2780
- ctxpackKey: String(flags["ctxpack-key"] || buildCtxpackKeyFromMeta(workspaceMeta) || "").trim(),
2781
- workspaceDir: resolveWorkspaceDir(flags["workspace-dir"] || process.cwd()),
3436
+ projectID: String(flags["project-id"] || "").trim(),
3437
+ ctxpackKey: String(flags["ctxpack-key"] || "").trim(),
3438
+ workspaceDir: explicitWorkspaceDir,
2782
3439
  includeDrafts: boolFromRaw(flags["include-drafts"], true),
2783
3440
  autoPullOnConflict: boolFromRaw(flags["auto-pull-on-conflict"], true),
2784
3441
  timeoutSeconds: intFromRaw(flags["timeout-seconds"], 30),
@@ -2845,12 +3502,16 @@ async function runProxy(flags) {
2845
3502
 
2846
3503
  const { name: toolName, args: toolArgs } = extractToolCall(requestObj);
2847
3504
  const requestWorkspaceCandidate = extractWorkspaceCandidateFromRequest(requestObj, toolArgs);
3505
+ const envWorkspaceCandidate = extractWorkspaceCandidateFromEnv();
2848
3506
  if (requestWorkspaceCandidate) {
2849
3507
  sessionWorkspaceDir = requestWorkspaceCandidate;
3508
+ } else if (envWorkspaceCandidate) {
3509
+ sessionWorkspaceDir = envWorkspaceCandidate;
2850
3510
  }
2851
3511
  const requestWorkspaceDir = resolveWorkspaceDir(
2852
3512
  firstNonEmptyString([
2853
3513
  requestWorkspaceCandidate,
3514
+ envWorkspaceCandidate,
2854
3515
  sessionWorkspaceDir,
2855
3516
  args.workspaceDir,
2856
3517
  process.cwd(),
@@ -2868,7 +3529,13 @@ async function runProxy(flags) {
2868
3529
  });
2869
3530
  }
2870
3531
  if (isJsonRpcMethod(requestObj, "tools/call") && LOCAL_PROJECT_TOOL_NAMES.includes(toolName)) {
2871
- const projectID = String(toolArgs.project_id || toolArgs.projectID || args.projectID || "").trim();
3532
+ const projectID = String(
3533
+ resolveProjectIDForRequest({
3534
+ toolArgs,
3535
+ args,
3536
+ workspaceDir: requestWorkspaceDir,
3537
+ }),
3538
+ ).trim();
2872
3539
  if (!projectID) {
2873
3540
  process.stdout.write(
2874
3541
  `${JSON.stringify(jsonRpcError(requestObj, -32001, "project_id is required (or set --project-id during setup)"))}\n`,
@@ -2924,6 +3591,113 @@ async function runProxy(flags) {
2924
3591
  }
2925
3592
  return;
2926
3593
  }
3594
+ if (isJsonRpcMethod(requestObj, "tools/call") && LOCAL_CTXPACK_MERGE_TOOL_NAMES.includes(toolName)) {
3595
+ const projectID = String(
3596
+ resolveProjectIDForRequest({
3597
+ toolArgs,
3598
+ args,
3599
+ workspaceDir: requestWorkspaceDir,
3600
+ }),
3601
+ ).trim();
3602
+ if (!projectID) {
3603
+ process.stdout.write(
3604
+ `${JSON.stringify(jsonRpcError(requestObj, -32001, "project_id is required (or set --project-id during setup)"))}\n`,
3605
+ );
3606
+ return;
3607
+ }
3608
+ if (!isUUID(projectID)) {
3609
+ process.stdout.write(
3610
+ `${JSON.stringify(jsonRpcError(requestObj, -32001, "project_id must be a valid UUID"))}\n`,
3611
+ );
3612
+ return;
3613
+ }
3614
+
3615
+ try {
3616
+ if (toolName === "ctxpack.merge.brief") {
3617
+ const statusRaw = String(toolArgs.status || "").trim().toLowerCase();
3618
+ const statusFilter = normalizeMergeStatusFilter(statusRaw);
3619
+ if (statusRaw && !statusFilter) {
3620
+ process.stdout.write(
3621
+ `${JSON.stringify(
3622
+ jsonRpcError(
3623
+ requestObj,
3624
+ -32001,
3625
+ "status must be one of: open, review, approved, rejected, merged, closed, all",
3626
+ ),
3627
+ )}\n`,
3628
+ );
3629
+ return;
3630
+ }
3631
+ const limit = Number.parseInt(String(toolArgs.limit || 20), 10) || 20;
3632
+ const brief = await loadCtxpackMergeBriefForTool({
3633
+ siteBaseURL: normalizeSiteBaseURL(args.baseURL),
3634
+ projectID,
3635
+ token,
3636
+ timeoutSeconds: args.timeoutSeconds,
3637
+ statusFilter: statusFilter || "open",
3638
+ limit,
3639
+ workspaceDir: requestWorkspaceDir,
3640
+ });
3641
+ const text = buildCtxpackMergeBriefText(brief);
3642
+ process.stdout.write(
3643
+ `${JSON.stringify(
3644
+ jsonRpcResult(requestObj, {
3645
+ content: [{ type: "text", text }],
3646
+ structuredContent: brief,
3647
+ }),
3648
+ )}\n`,
3649
+ );
3650
+ return;
3651
+ }
3652
+
3653
+ if (toolName === "ctxpack.merge.execute") {
3654
+ const mergeRequestID = String(toolArgs.merge_request_id || toolArgs.mergeRequestID || "").trim();
3655
+ const action = normalizeMergeAction(toolArgs.action);
3656
+ const ownerConfirmation = String(
3657
+ toolArgs.owner_confirmation || toolArgs.ownerConfirmation || "",
3658
+ ).trim();
3659
+ if (!mergeRequestID || !isUUID(mergeRequestID)) {
3660
+ process.stdout.write(
3661
+ `${JSON.stringify(jsonRpcError(requestObj, -32001, "merge_request_id must be a valid UUID"))}\n`,
3662
+ );
3663
+ return;
3664
+ }
3665
+ if (!action) {
3666
+ process.stdout.write(
3667
+ `${JSON.stringify(
3668
+ jsonRpcError(requestObj, -32001, "action must be one of: review, approve, reject, close, merge"),
3669
+ )}\n`,
3670
+ );
3671
+ return;
3672
+ }
3673
+ const result = await executeCtxpackMergeActionForTool({
3674
+ siteBaseURL: normalizeSiteBaseURL(args.baseURL),
3675
+ projectID,
3676
+ mergeRequestID,
3677
+ action,
3678
+ ownerConfirmation,
3679
+ token,
3680
+ timeoutSeconds: args.timeoutSeconds,
3681
+ workspaceDir: requestWorkspaceDir,
3682
+ });
3683
+ const text = buildCtxpackMergeExecuteText(result);
3684
+ process.stdout.write(
3685
+ `${JSON.stringify(
3686
+ jsonRpcResult(requestObj, {
3687
+ content: [{ type: "text", text }],
3688
+ structuredContent: result,
3689
+ }),
3690
+ )}\n`,
3691
+ );
3692
+ return;
3693
+ }
3694
+ } catch (err) {
3695
+ process.stdout.write(
3696
+ `${JSON.stringify(jsonRpcError(requestObj, -32001, String(err?.message || err)))}\n`,
3697
+ );
3698
+ return;
3699
+ }
3700
+ }
2927
3701
 
2928
3702
  try {
2929
3703
  const requestWithDefaults = injectCtxpackPushDefaults(requestObj, toolName, requestWorkspaceDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [