@merchantduo/code 0.3.0-beta.1 → 0.4.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,11 @@ export default function context(pi) {
13
13
  if (state.environmentStatus === "stopped") {
14
14
  refreshStatus(ctx, state);
15
15
  sessionEvents.emit("merchantduo.context", state);
16
- pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode });
16
+ pi.appendEntry("merchantduo.context", {
17
+ environment: state.selected.name,
18
+ environmentStatus: state.environmentStatus,
19
+ permissionMode: state.permissionMode,
20
+ });
17
21
  return;
18
22
  }
19
23
  const record = (argv, result) => state.nativeLog.record(state.backend.command(argv), result);
@@ -23,27 +27,70 @@ export default function context(pi) {
23
27
  // Prefer non-Snap Chrome: Snap Chromium cannot write MerchantDuo artifacts
24
28
  // and its private /tmp makes generated output unavailable to the host.
25
29
  const hostRunner = new NodeHostProcessRunner();
26
- const nativeHost = { run: async (file, args, options) => {
30
+ const nativeHost = {
31
+ run: async (file, args, options) => {
27
32
  const result = await hostRunner.run(file, args, options);
28
- await state.nativeLog.record({ file, args, cwd: options.cwd }, { stdout: result.output, stderr: "", exitCode: result.code, truncated: result.truncated ?? false });
33
+ await state.nativeLog.record({ file, args, cwd: options.cwd }, {
34
+ stdout: result.output,
35
+ stderr: "",
36
+ exitCode: result.code,
37
+ truncated: result.truncated ?? false,
38
+ });
29
39
  return result;
30
- } };
40
+ },
41
+ };
31
42
  const [http, browser] = await Promise.all([
32
43
  discoverHostExecutable(["curl"], nativeHost),
33
- discoverHostExecutable(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"], nativeHost),
44
+ discoverHostExecutable([
45
+ "google-chrome",
46
+ "google-chrome-stable",
47
+ "chromium",
48
+ "chromium-browser",
49
+ ], nativeHost),
34
50
  ]);
35
51
  runtime.setMagerun2(magerun2);
36
- runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, { ...state.config.testing, ...state.selected.environment.testing, allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ?? state.config.testing.allowInsecureTls }, { http, browser }, record));
52
+ runtime.setTesting(await discoverTesting(state.selected.environment, state.backend, ctx.cwd, {
53
+ ...state.config.testing,
54
+ ...state.selected.environment.testing,
55
+ allowInsecureTls: state.selected.environment.testing?.allowInsecureTls ??
56
+ state.config.testing.allowInsecureTls,
57
+ }, { http, browser }, record));
37
58
  refreshStatus(ctx, state);
38
59
  sessionEvents.emit("merchantduo.context", state);
39
- pi.appendEntry("merchantduo.context", { environment: state.selected.name, environmentStatus: state.environmentStatus, permissionMode: state.permissionMode, testing: state.testing, magerun2: state.magerun2 });
60
+ pi.appendEntry("merchantduo.context", {
61
+ environment: state.selected.name,
62
+ environmentStatus: state.environmentStatus,
63
+ permissionMode: state.permissionMode,
64
+ testing: state.testing,
65
+ magerun2: state.magerun2,
66
+ });
40
67
  });
41
68
  pi.on("session_compact", () => runtime.resetInjection());
42
69
  pi.on("session_shutdown", () => sessionEvents.clear());
43
- pi.registerCommand("duo-status", { description: "Show the current MerchantDuo session status.", handler: async (_args, ctx) => {
70
+ pi.registerCommand("duo-status", {
71
+ description: "Show the current MerchantDuo session status.",
72
+ handler: async (_args, ctx) => {
44
73
  const state = await runtime.boot(ctx.cwd);
45
- pi.sendMessage({ customType: "merchantduo.status", content: statusMessage(state), display: true, details: {} });
46
- } });
74
+ pi.sendMessage({
75
+ customType: "merchantduo.status",
76
+ content: statusMessage(state),
77
+ display: true,
78
+ details: {},
79
+ });
80
+ },
81
+ });
82
+ pi.registerCommand("duo-upgrade-core", {
83
+ description: "Assess available Magento core upgrades, present plans, and proceed after a chat selection.",
84
+ handler: async (_args, ctx) => {
85
+ const state = await runtime.boot(ctx.cwd);
86
+ if (state.environmentStatus === "stopped") {
87
+ ctx.ui.notify("Start the selected environment before assessing a Magento core upgrade.", "error");
88
+ return;
89
+ }
90
+ pi.sendUserMessage(coreUpgradePrompt(state));
91
+ ctx.ui.notify("Asked the agent to assess Magento core-upgrade plans from project and release evidence.", "info");
92
+ },
93
+ });
47
94
  pi.on("tool_call", (event) => {
48
95
  if (event.toolName !== "write" && event.toolName !== "edit")
49
96
  return;
@@ -61,12 +108,45 @@ export default function context(pi) {
61
108
  const state = await runtime.boot(ctx.cwd);
62
109
  const operatorRole = await loadOperatorRole();
63
110
  const testing = state.testing;
64
- return { systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt(operatorRole)}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${state.environmentStatus === "stopped" ? "Use environment_start only after the user directly asks to start it; do not use project tools until it is running." : "Testing URLs are discovered but unverified. Frontend: " + (testing?.frontendUrl ?? "unavailable") + "; Admin: " + (testing?.adminUrl ?? "unavailable") + ". HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant."}` };
111
+ return {
112
+ systemPrompt: `${event.systemPrompt}\n${merchantDuoSystemPrompt(operatorRole)}\nEnvironment: ${state.selected.name} is ${state.environmentStatus}; permission mode=${state.permissionMode}. ${state.environmentStatus === "stopped" ? "Use environment_start only after the user directly asks to start it; do not use project tools until it is running." : "Testing URLs are discovered but unverified. Frontend: " + (testing?.frontendUrl ?? "unavailable") + "; Admin: " + (testing?.adminUrl ?? "unavailable") + ". HTTP, browser, navigation, and test artifacts are host-only; never use environment bash to locate or repair their host paths. Use explicit test tools only when relevant."}`,
113
+ };
65
114
  });
66
115
  }
116
+ /**
117
+ * Algorithm: inspect the selected instance and authoritative release evidence without mutation;
118
+ * compare every newer core target against the instance; present plans; wait for one chat choice;
119
+ * then constrain later upgrade work to that selected target and the normal permission boundaries.
120
+ */
121
+ export function coreUpgradePrompt(state) {
122
+ const current = [
123
+ state.magento.edition ?? "unknown edition",
124
+ state.magento.version ?? "version unavailable",
125
+ state.magento.phpVersion ? `PHP ${state.magento.phpVersion}` : undefined,
126
+ ]
127
+ .filter(Boolean)
128
+ .join(", ");
129
+ return `Start an agentic Magento core-upgrade assessment for the selected ${state.selected.name} environment. The current startup snapshot is ${current}; verify it from the real project before relying on it.
130
+
131
+ This assessment phase is read-only. Do not edit files, change Composer dependencies, run Magento setup/deployment commands, enable maintenance mode, or make any other mutation before the merchant explicitly selects a plan below.
132
+
133
+ First inspect the actual Magento instance: its edition and core package/version, root composer.json and composer.lock, Composer platform constraints, runtime PHP and relevant service requirements, custom app/code and themes, installed Composer packages, and third-party extension compatibility evidence. Never read, print, or expose deployment credentials or other secrets.
134
+
135
+ Determine every newer stable Magento core version available for this edition. Prefer Composer package metadata for installable-version availability and official Adobe/Magento release notes for support status, platform requirements, security fixes, breaking changes, and merchant-facing benefits. Do not infer compatibility from version numbers alone. Mark every unresolved package, customization, service, or deployment dependency as unknown rather than guessing.
136
+
137
+ Present the results in this order:
138
+ 1. verified current state and evidence;
139
+ 2. every newer available core version, clearly labeling unsupported or end-of-life targets;
140
+ 3. a short numbered set of practical upgrade plans. For each plan, state the target version, effort tier and rationale, prerequisites/blockers, merchant benefits, project-specific risks, and required operational/validation work. Do not make calendar promises from incomplete evidence.
141
+
142
+ Ask the merchant to select exactly one plan by its number or target version, then stop and wait. Do not start the upgrade while presenting options. After an explicit selection, treat it as authority to proceed only with that chosen Magento core target: make only compatibility updates required for that target, not opportunistic extension upgrades. Keep every existing MerchantDuo workspace permission and operational confirmation boundary; use the supported Magento workflow tools for their covered operations.`;
143
+ }
67
144
  export function statusMessage(state) {
68
145
  const lines = [
69
146
  `Environment: ${state.selected.name} (${state.environmentStatus})`,
147
+ state.magento.database.status === "unavailable"
148
+ ? "Database: unavailable"
149
+ : `Database: ${state.magento.database.location} ${state.magento.database.name} (${state.magento.database.status === "ready" ? "ready" : "not ready"})`,
70
150
  `Permission mode: ${state.permissionMode}`,
71
151
  `Magento: ${state.magento.edition ?? "unknown"} ${state.magento.version ?? "version unavailable"}`,
72
152
  `PHP: ${state.magento.phpVersion ?? "unavailable"}`,
@@ -76,8 +156,11 @@ export function statusMessage(state) {
76
156
  `Admin URL: ${state.testing?.adminUrl ?? "unavailable"}`,
77
157
  `Capabilities: HTTP ${state.testing?.http.available ? "available" : "unavailable"}; browser ${state.testing?.browser.available ? "available" : "unavailable"}; magerun2 ${state.magerun2?.available ? "available" : "unavailable"}`,
78
158
  ...state.magento.warnings.map((warning) => `Warning: ${warning}`),
79
- ...(state.testing?.diagnostics.map((warning) => `Warning: ${warning}`) ?? []),
80
- ...(state.magerun2?.diagnostic ? [`Warning: ${state.magerun2.diagnostic}`] : []),
159
+ ...(state.testing?.diagnostics.map((warning) => `Warning: ${warning}`) ??
160
+ []),
161
+ ...(state.magerun2?.diagnostic
162
+ ? [`Warning: ${state.magerun2.diagnostic}`]
163
+ : []),
81
164
  ...(state.nativeLog.warning ? [`Warning: ${state.nativeLog.warning}`] : []),
82
165
  ];
83
166
  return lines.join("\n");
@@ -3,32 +3,68 @@ import { refreshStatus } from "#app/status";
3
3
  import { runtime, text } from "#integrations/pi/session";
4
4
  /** Warden is managed directly; a local project's lifecycle is selected by the agent from live project evidence. */
5
5
  export default function environment(pi) {
6
- pi.registerTool({ name: "environment_status", label: "Environment status", description: "Report whether the selected MerchantDuo environment is running or stopped.", parameters: Type.Object({}), async execute(_id, _params, _signal, _update, ctx) {
6
+ pi.registerTool({
7
+ name: "environment_status",
8
+ label: "Environment status",
9
+ description: "Report whether the selected MerchantDuo environment is running or stopped.",
10
+ parameters: Type.Object({}),
11
+ async execute(_id, _params, _signal, _update, ctx) {
7
12
  const state = await runtime.boot(ctx.cwd);
8
13
  return text(environmentMessage(state));
9
- } });
10
- pi.registerTool({ name: "environment_start", label: "Start environment", description: "Start Warden directly, or run the agent-selected local lifecycle command after inspection.", parameters: Type.Object({ command: Type.Optional(Type.String({ minLength: 1 })) }), async execute(_id, params, signal, _update, ctx) {
14
+ },
15
+ });
16
+ pi.registerTool({
17
+ name: "environment_start",
18
+ label: "Start environment",
19
+ description: "Start Warden directly, or run the agent-selected local lifecycle command after inspection.",
20
+ parameters: Type.Object({
21
+ command: Type.Optional(Type.String({ minLength: 1 })),
22
+ }),
23
+ async execute(_id, params, signal, _update, ctx) {
11
24
  const state = await change("start", ctx.cwd, params.command, signal);
12
25
  refreshStatus(ctx, state);
13
26
  return text(environmentMessage(state));
14
- } });
15
- pi.registerTool({ name: "environment_stop", label: "Stop environment", description: "Stop Warden directly, or run the agent-selected local lifecycle command after inspection.", parameters: Type.Object({ command: Type.Optional(Type.String({ minLength: 1 })) }), async execute(_id, params, signal, _update, ctx) {
27
+ },
28
+ });
29
+ pi.registerTool({
30
+ name: "environment_stop",
31
+ label: "Stop environment",
32
+ description: "Stop Warden directly, or run the agent-selected local lifecycle command after inspection.",
33
+ parameters: Type.Object({
34
+ command: Type.Optional(Type.String({ minLength: 1 })),
35
+ }),
36
+ async execute(_id, params, signal, _update, ctx) {
16
37
  const state = await change("stop", ctx.cwd, params.command, signal);
17
38
  refreshStatus(ctx, state);
18
39
  return text(environmentMessage(state));
19
- } });
20
- pi.registerTool({ name: "environment_set_status", label: "Set environment status", description: "Record the local environment lifecycle status after inspecting or running its project-specific commands. Include concise command/output evidence.", parameters: Type.Object({ status: Type.Union([Type.Literal("running"), Type.Literal("stopped")]), evidence: Type.String({ minLength: 1 }) }), async execute(_id, params, _signal, _update, ctx) {
40
+ },
41
+ });
42
+ pi.registerTool({
43
+ name: "environment_set_status",
44
+ label: "Set environment status",
45
+ description: "Record the local environment lifecycle status after inspecting or running its project-specific commands. Include concise command/output evidence.",
46
+ parameters: Type.Object({
47
+ status: Type.Union([Type.Literal("running"), Type.Literal("stopped")]),
48
+ evidence: Type.String({ minLength: 1 }),
49
+ }),
50
+ async execute(_id, params, _signal, _update, ctx) {
21
51
  const state = await runtime.boot(ctx.cwd);
22
52
  if (state.selected.environment.type !== "local")
23
53
  throw new Error("Only local environment status is agent-reported.");
24
- refreshStatus(ctx, runtime.setEnvironmentStatus(params.status));
54
+ const changed = runtime.setEnvironmentStatus(params.status);
55
+ if (params.status === "running")
56
+ await runtime.refreshMagento(ctx.cwd);
57
+ refreshStatus(ctx, changed);
25
58
  return text(`Local environment marked ${params.status}: ${params.evidence}`);
26
- } });
59
+ },
60
+ });
27
61
  registerCommand(pi, "duo-env-start", "start");
28
62
  registerCommand(pi, "duo-env-stop", "stop");
29
63
  }
30
64
  function registerCommand(pi, name, action) {
31
- pi.registerCommand(name, { description: `${action === "start" ? "Start" : "Stop"} the selected environment. Warden is direct; local is agent-directed.`, handler: async (_args, ctx) => {
65
+ pi.registerCommand(name, {
66
+ description: `${action === "start" ? "Start" : "Stop"} the selected environment. Warden is direct; local is agent-directed.`,
67
+ handler: async (_args, ctx) => {
32
68
  const state = await runtime.boot(ctx.cwd);
33
69
  if (state.selected.environment.type === "local") {
34
70
  pi.sendUserMessage(localLifecyclePrompt(action));
@@ -38,7 +74,8 @@ function registerCommand(pi, name, action) {
38
74
  const changed = await change(action, ctx.cwd);
39
75
  refreshStatus(ctx, changed);
40
76
  ctx.ui.notify(environmentMessage(changed), "info");
41
- } });
77
+ },
78
+ });
42
79
  }
43
80
  async function change(action, cwd, command, signal) {
44
81
  const state = await runtime.boot(cwd);
@@ -49,16 +86,25 @@ async function change(action, cwd, command, signal) {
49
86
  throw new Error(`Inspect the local project first, then call environment_${action} with its exact lifecycle command.`);
50
87
  const result = await state.backend.run(["sh", "-lc", command], { signal });
51
88
  if (result.exitCode !== 0)
52
- throw new Error(result.stderr || result.stdout || `Could not ${action} local environment`);
53
- return runtime.setEnvironmentStatus(action === "start" ? "running" : "stopped");
89
+ throw new Error(result.stderr ||
90
+ result.stdout ||
91
+ `Could not ${action} local environment`);
92
+ const changed = runtime.setEnvironmentStatus(action === "start" ? "running" : "stopped");
93
+ return action === "start" ? runtime.refreshMagento(cwd) : changed;
54
94
  }
55
- return action === "start" ? runtime.startEnvironment(cwd) : runtime.stopEnvironment(cwd);
95
+ return action === "start"
96
+ ? runtime.startEnvironment(cwd)
97
+ : runtime.stopEnvironment(cwd);
56
98
  }
57
99
  function localLifecyclePrompt(action) {
58
100
  return `The user directly requested that you ${action} the selected local environment. This local stack is not assumed to use any one runtime. Inspect the project documentation, package scripts, Compose files, service configuration, and existing process state first. Determine the smallest correct ${action} command, explain the evidence and intended effect, then execute it through environment_${action} with the exact command. Do not use a generic command, do not stop unrelated host services, and if the project lifecycle cannot be determined safely, ask the user. After a successful command, the tool records ${action === "start" ? "running" : "stopped"}.`;
59
101
  }
60
102
  function environmentMessage(state) {
61
103
  const environment = state.selected.environment;
62
- const managed = environment.type === "warden" ? "Warden-managed" : environment.type === "local" ? "agent-directed" : "remote unmanaged";
104
+ const managed = environment.type === "warden"
105
+ ? "Warden-managed"
106
+ : environment.type === "local"
107
+ ? "agent-directed"
108
+ : "remote unmanaged";
63
109
  return `${state.selected.name} (${environment.type}): ${state.environmentStatus} [${managed}]`;
64
110
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { type PermissionMode } from "#app/permission-mode";
3
3
  import type { Environment } from "#environments/model";
4
+ export declare function permissionChoices(): PermissionMode[];
4
5
  export declare function requiresExecuteConfirmation(toolName: string, input: Record<string, unknown>): boolean;
5
6
  /** Return block/confirmation behavior; callers retain all hard capability constraints. */
6
7
  export declare function permissionDecision(mode: PermissionMode, environment: Environment, toolName: string, input: Record<string, unknown>): "allow" | "block" | "confirm";
@@ -3,6 +3,9 @@ import { refreshStatus } from "#app/status";
3
3
  import { runtime } from "#integrations/pi/session";
4
4
  const workspaceMutations = new Set(["write", "edit"]);
5
5
  const stoppedAllowed = new Set(["environment_status", "environment_start", "environment_stop", "environment_set_status"]);
6
+ export function permissionChoices() {
7
+ return [...permissionModes];
8
+ }
6
9
  export function requiresExecuteConfirmation(toolName, input) {
7
10
  return ["environment_start", "environment_stop"].includes(toolName) || ((toolName === "mage2gen_generate_module" || toolName === "magento_workflow" || toolName === "magerun2") && input.execute === true);
8
11
  }
@@ -26,9 +29,13 @@ async function confirm(ctx, title, message) {
26
29
  /** The sole MerchantDuo confirmation gate. YOLO removes dialogs, never hard constraints. */
27
30
  export default function permissions(pi) {
28
31
  pi.registerCommand("duo-switch-permissions", { description: "Set this session's permission mode: read-only, normal, or yolo.", handler: async (args, ctx) => {
29
- const mode = args.trim();
30
- if (!permissionModes.includes(mode)) {
31
- ctx.ui.notify("Choose one of: read-only, normal, yolo", "error");
32
+ const choices = permissionChoices();
33
+ const typed = args.trim();
34
+ const mode = typed || (ctx.hasUI ? await ctx.ui.select("Select session permission mode", choices) : undefined);
35
+ if (!mode)
36
+ return;
37
+ if (!choices.includes(mode)) {
38
+ ctx.ui.notify(`Choose one of: ${choices.join(", ")}`, "error");
32
39
  return;
33
40
  }
34
41
  await runtime.boot(ctx.cwd);
@@ -16,6 +16,7 @@ type SearchParameters = {
16
16
  */
17
17
  export declare function searchCommand(params: SearchParameters, path: string): string;
18
18
  export declare function workspacePath(backend: EnvironmentBackend, cwd: string, candidate: string): string;
19
+ export declare function wardenHostPathWarning(backend: EnvironmentBackend, cwd: string, candidate: string): string | undefined;
19
20
  /** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
20
21
  export declare function packagedSkillPath(candidate: string): string | undefined;
21
22
  export {};
@@ -1,9 +1,10 @@
1
1
  import { access as hostAccess, readFile as hostReadFile } from "node:fs/promises";
2
- import { relative, resolve } from "node:path";
2
+ import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { Type } from "typebox";
4
4
  import { createEditToolDefinition, createReadToolDefinition, createWriteToolDefinition, } from "@earendil-works/pi-coding-agent";
5
5
  import { runtime } from "#integrations/pi/session";
6
6
  import { packagePath } from "#shared/package-paths";
7
+ import { postEditHints, validateMagentoXml } from "#workflows/post-edit";
7
8
  const shell = (value) => `'${value.replace(/'/g, "'\\''")}'`;
8
9
  const result = (value) => ({ content: [{ type: "text", text: value }], details: {} });
9
10
  const packageSkillRoots = [
@@ -34,9 +35,19 @@ function registerFileTools(pi) {
34
35
  const localRead = createReadToolDefinition(process.cwd());
35
36
  const localWrite = createWriteToolDefinition(process.cwd());
36
37
  const localEdit = createEditToolDefinition(process.cwd());
37
- pi.registerTool({ ...localRead, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createReadToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
38
- pi.registerTool({ ...localWrite, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createWriteToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
39
- pi.registerTool({ ...localEdit, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); return createEditToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); } });
38
+ pi.registerTool({ ...localRead, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createReadToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendNotice(response, wardenHostPathWarning(state.backend, ctx.cwd, params.path)); } });
39
+ pi.registerTool({ ...localWrite, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createWriteToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendPostEdit(response, state.backend, ctx.cwd, params.path, signal); } });
40
+ pi.registerTool({ ...localEdit, async execute(id, params, signal, update, ctx) { const state = await runtime.boot(ctx.cwd); const response = await createEditToolDefinition(ctx.cwd, { operations: fileOps(state.backend, ctx.cwd) }).execute(id, params, signal, update, ctx); return appendPostEdit(response, state.backend, ctx.cwd, params.path, signal); } });
41
+ }
42
+ async function appendPostEdit(response, backend, cwd, path, signal) {
43
+ const target = workspacePath(backend, cwd, path);
44
+ const validation = await validateMagentoXml(backend, target, signal);
45
+ return appendNotice(response, [wardenHostPathWarning(backend, cwd, path), ...postEditHints(path), validation].filter((value) => Boolean(value)).join("\n"));
46
+ }
47
+ function appendNotice(response, notice) {
48
+ if (!notice)
49
+ return response;
50
+ return { ...response, content: [...response.content, { type: "text", text: `MerchantDuo: ${notice}` }] };
40
51
  }
41
52
  function fileOps(backend, cwd) {
42
53
  const readFile = async (path) => {
@@ -58,8 +69,27 @@ function fileOps(backend, cwd) {
58
69
  throw new Error("Path is inaccessible"); }, mkdir: async (path) => { const response = await backend.run(["mkdir", "-p", workspacePath(backend, cwd, path)]); if (response.exitCode)
59
70
  throw new Error(response.stderr || "Cannot create directory"); } };
60
71
  }
61
- export function workspacePath(backend, cwd, candidate) { const full = resolve(cwd, candidate); const value = relative(resolve(cwd), full); if (value === ".." || value.startsWith("../"))
62
- throw new Error(`Path escapes workspace: ${candidate}`); return backend.path(value); }
72
+ export function workspacePath(backend, cwd, candidate) {
73
+ if (backend.environment.type === "warden" && isAbsolute(candidate)) {
74
+ const containerPath = resolve(candidate);
75
+ if (contains(resolve(backend.environment.root), containerPath))
76
+ return containerPath;
77
+ }
78
+ const full = resolve(cwd, candidate);
79
+ const value = relative(resolve(cwd), full);
80
+ if (value === ".." || value.startsWith("../"))
81
+ throw new Error(`Path escapes workspace: ${candidate}`);
82
+ return backend.path(value);
83
+ }
84
+ export function wardenHostPathWarning(backend, cwd, candidate) {
85
+ if (backend.environment.type !== "warden" || !isAbsolute(candidate))
86
+ return undefined;
87
+ const hostPath = resolve(candidate);
88
+ if (!contains(resolve(cwd), hostPath))
89
+ return undefined;
90
+ const value = relative(resolve(cwd), hostPath);
91
+ return `Warden path warning: ${candidate} was mapped to ${backend.path(value)}. This session runs inside Warden; use the container path next time.`;
92
+ }
63
93
  /** Return only a declared MerchantDuo skill asset; project files remain environment-routed. */
64
94
  export function packagedSkillPath(candidate) {
65
95
  const full = resolve(candidate);
@@ -1,5 +1,5 @@
1
1
  export { parseDeployMode } from "#magento/deploy-mode";
2
2
  export { DefaultMagentoInspector, type MagentoInspector, } from "#magento/inspector";
3
- export type { MagentoDeployMode, MagentoSnapshot, MagentoTheme, RawTheme, } from "#magento/model";
3
+ export type { MagentoDeployMode, MagentoDatabaseLocation, MagentoDatabaseSnapshot, MagentoDatabaseStatus, MagentoSnapshot, MagentoTheme, RawTheme, } from "#magento/model";
4
4
  export { snapshotPrompt } from "#magento/prompt";
5
5
  export { parseRegisteredThemes, resolveThemes } from "#magento/themes";
@@ -1,9 +1,10 @@
1
1
  import type { CommandExecutor, CommandResult } from "#environments/executor";
2
+ import type { Environment } from "#environments/model";
2
3
  import type { MagentoSnapshot } from "#magento/model";
3
4
  export interface MagentoInspector {
4
- inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
5
+ inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>, environment?: Environment): Promise<MagentoSnapshot>;
5
6
  }
6
7
  /** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
7
8
  export declare class DefaultMagentoInspector implements MagentoInspector {
8
- inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>): Promise<MagentoSnapshot>;
9
+ inspect(executor: CommandExecutor, record?: (argv: string[], result: CommandResult) => Promise<void>, environment?: Environment): Promise<MagentoSnapshot>;
9
10
  }
@@ -2,7 +2,7 @@ import { parseDeployMode } from "#magento/deploy-mode";
2
2
  import { parseRegisteredThemes, resolveThemes } from "#magento/themes";
3
3
  /** Ordered, fail-soft Magento inspection. New probes can be extracted without changing consumers. */
4
4
  export class DefaultMagentoInspector {
5
- async inspect(executor, record) {
5
+ async inspect(executor, record, environment) {
6
6
  const warnings = [];
7
7
  const evidence = [];
8
8
  const run = async (argv) => {
@@ -34,11 +34,12 @@ export class DefaultMagentoInspector {
34
34
  cliVersion &&
35
35
  !cliVersion.startsWith(composerVersion))
36
36
  warnings.push(`Composer (${composerVersion}) and CLI (${cliVersion}) disagree`);
37
- const [php, mode, cache, registeredThemes] = await Promise.all([
37
+ const [php, mode, cache, registeredThemes, database] = await Promise.all([
38
38
  run(["php", "-r", "echo PHP_VERSION;"]),
39
39
  run(["bin/magento", "deploy:mode:show"]),
40
40
  run(["bin/magento", "cache:status", "--no-ansi"]),
41
41
  run(["sh", "-lc", themeDiscoveryScript]),
42
+ inspectDatabase(run, environment),
42
43
  ]);
43
44
  return {
44
45
  version: composerVersion ?? cliVersion,
@@ -49,12 +50,60 @@ export class DefaultMagentoInspector {
49
50
  .split("\n")
50
51
  .map((line) => line.match(/^([^:]+):/)?.[1])
51
52
  .filter((value) => Boolean(value)),
53
+ database,
52
54
  warnings,
53
55
  evidence,
54
56
  themes: resolveThemes(parseRegisteredThemes(registeredThemes.stdout)),
55
57
  };
56
58
  }
57
59
  }
60
+ /**
61
+ * Read only the non-secret connection metadata inside the selected runtime.
62
+ * The command emits location/name, never the configured host or credentials.
63
+ */
64
+ async function inspectDatabase(run, environment) {
65
+ const localHosts = ["", "localhost", "127.0.0.1", "::1"];
66
+ if (environment?.type === "warden")
67
+ localHosts.push("db");
68
+ const metadata = await run([
69
+ "sh",
70
+ "-lc",
71
+ databaseMetadataCommand(localHosts),
72
+ ]);
73
+ const parsed = parseDatabaseMetadata(metadata.stdout);
74
+ if (!parsed)
75
+ return { status: "unavailable" };
76
+ const readiness = await run([
77
+ "sh",
78
+ "-lc",
79
+ "bin/magento setup:db:status >/dev/null 2>&1",
80
+ ]);
81
+ return {
82
+ ...parsed,
83
+ status: readiness.exitCode === 0 ? "ready" : "not-ready",
84
+ };
85
+ }
86
+ function databaseMetadataCommand(localHosts) {
87
+ const localHostsJson = JSON.stringify(localHosts);
88
+ const script = `try { $config = require "app/etc/env.php"; $connection = $config["db"]["connection"]["default"] ?? null; $name = is_array($connection) ? ($connection["dbname"] ?? null) : null; $host = is_array($connection) ? ($connection["host"] ?? "") : ""; $local = in_array((string) $host, ${localHostsJson}, true); if (!is_string($name) || $name === "") { echo "{}"; } else { echo json_encode(["location" => $local ? "local" : "remote", "name" => $name]); } } catch (Throwable $error) { echo "{}"; }`;
89
+ return `php -d display_errors=0 -r '${script}' 2>/dev/null`;
90
+ }
91
+ function parseDatabaseMetadata(stdout) {
92
+ try {
93
+ const value = JSON.parse(stdout.trim());
94
+ if (!value || typeof value !== "object")
95
+ return undefined;
96
+ const { location, name } = value;
97
+ if ((location !== "local" && location !== "remote") ||
98
+ typeof name !== "string" ||
99
+ !name)
100
+ return undefined;
101
+ return { location, name };
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
58
107
  const themeDiscoveryScript = `{
59
108
  find vendor -mindepth 3 -maxdepth 3 -type f -name registration.php 2>/dev/null
60
109
  find app/design -mindepth 4 -maxdepth 4 -type f -name registration.php 2>/dev/null
@@ -1,4 +1,11 @@
1
1
  export type MagentoDeployMode = "default" | "developer" | "production";
2
+ export type MagentoDatabaseLocation = "local" | "remote";
3
+ export type MagentoDatabaseStatus = "ready" | "not-ready" | "unavailable";
4
+ export type MagentoDatabaseSnapshot = {
5
+ location?: MagentoDatabaseLocation;
6
+ name?: string;
7
+ status: MagentoDatabaseStatus;
8
+ };
2
9
  export type MagentoTheme = {
3
10
  code: string;
4
11
  title?: string;
@@ -18,6 +25,7 @@ export type MagentoSnapshot = {
18
25
  phpVersion?: string;
19
26
  mode?: MagentoDeployMode;
20
27
  cacheTypes: string[];
28
+ database: MagentoDatabaseSnapshot;
21
29
  warnings: string[];
22
30
  evidence: string[];
23
31
  themes: MagentoTheme[];
@@ -0,0 +1,7 @@
1
+ import type { EnvironmentBackend } from "#environments/backend";
2
+ /** Keep immediate post-edit guidance narrow; operational work remains an explicit request. */
3
+ export declare function postEditHints(path: string): string[];
4
+ export declare function isMagentoXml(path: string): boolean;
5
+ /** Validate changed Magento XML in its own selected environment using Magento's URN resolver. */
6
+ export declare function validateMagentoXml(backend: EnvironmentBackend, path: string, signal?: AbortSignal): Promise<string>;
7
+ export declare function xmlValidationCommand(path: string): string[];
@@ -0,0 +1,51 @@
1
+ const xsdValidator = String.raw `
2
+ $file = $argv[1] ?? '';
3
+ libxml_use_internal_errors(true);
4
+ $dom = new DOMDocument();
5
+ if (!$dom->load($file)) {
6
+ foreach (libxml_get_errors() as $error) fwrite(STDERR, trim($error->message) . " at line " . $error->line . "\n");
7
+ exit(2);
8
+ }
9
+ $root = $dom->documentElement;
10
+ $schema = $root ? $root->getAttributeNS('http://www.w3.org/2001/XMLSchema-instance', 'noNamespaceSchemaLocation') : '';
11
+ if (!$schema || strpos($schema, 'urn:magento:') !== 0) {
12
+ fwrite(STDERR, "Magento XML must declare xsi:noNamespaceSchemaLocation with a urn:magento schema.\n");
13
+ exit(2);
14
+ }
15
+ require getcwd() . '/app/bootstrap.php';
16
+ $errors = Magento\Framework\Config\Dom::validateDomDocument($dom, $schema);
17
+ if ($errors) {
18
+ foreach ($errors as $error) fwrite(STDERR, (string)$error . "\n");
19
+ exit(2);
20
+ }
21
+ echo "Magento XML XSD validation passed.\n";
22
+ `;
23
+ /** Keep immediate post-edit guidance narrow; operational work remains an explicit request. */
24
+ export function postEditHints(path) {
25
+ const value = path.toLowerCase();
26
+ const hints = ["Inspect the changed file and its closest Magento precedent before continuing."];
27
+ if (value.endsWith(".php") || value.endsWith(".phtml"))
28
+ hints.push("Use magento_workflow action=syntax-check when PHP syntax verification is relevant.");
29
+ if (value.endsWith(".xml"))
30
+ hints.push("Review the XSD result below; preview a cache clean only when this configuration change requires it.");
31
+ if (value.endsWith("etc/module.xml") || value.endsWith("db_schema.xml") || value.endsWith("composer.json"))
32
+ hints.push("This may require setup:upgrade; explain the evidence and use magento_workflow only after the user directly requests execution.");
33
+ return hints;
34
+ }
35
+ export function isMagentoXml(path) {
36
+ return path.toLowerCase().endsWith(".xml")
37
+ && /(?:^|\/)(?:app\/(?:code|design)|vendor)\//.test(path.replace(/\\/g, "/"));
38
+ }
39
+ /** Validate changed Magento XML in its own selected environment using Magento's URN resolver. */
40
+ export async function validateMagentoXml(backend, path, signal) {
41
+ if (!isMagentoXml(path))
42
+ return "";
43
+ const output = await backend.run(xmlValidationCommand(path), { signal });
44
+ const detail = `${output.stdout}${output.stderr}`.trim();
45
+ return output.exitCode === 0
46
+ ? detail || "Magento XML XSD validation passed."
47
+ : `Warning: Magento XML XSD validation failed for ${path}: ${detail || "validation could not run."}`;
48
+ }
49
+ export function xmlValidationCommand(path) {
50
+ return ["php", "-r", xsdValidator, path];
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@merchantduo/code",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.4.0-beta.1",
4
4
  "private": false,
5
5
  "description": "Magento-native Pi-based Coding Agent",
6
6
  "type": "module",
@@ -57,18 +57,18 @@
57
57
  ]
58
58
  },
59
59
  "dependencies": {
60
- "@earendil-works/pi-coding-agent": "0.84.1",
60
+ "@earendil-works/pi-coding-agent": "0.84.3",
61
61
  "@modelcontextprotocol/client": "2.0.0",
62
62
  "pi-goal": "0.1.7",
63
- "pi-web-access": "0.22.0",
64
- "typebox": "1.3.7",
65
- "yaml": "^2.8.1",
66
- "zod": "^3.24.4"
63
+ "pi-web-access": "0.25.0",
64
+ "typebox": "1.3.19",
65
+ "yaml": "^2.9.0",
66
+ "zod": "^4.4.3"
67
67
  },
68
68
  "devDependencies": {
69
- "@types/node": "^22.15.3",
69
+ "@types/node": "^26.4.0",
70
70
  "prettier": "^3.9.6",
71
- "typescript": "^5.8.3"
71
+ "typescript": "^7.0.2"
72
72
  },
73
73
  "scripts": {
74
74
  "clean": "node -e \"const fs=require('node:fs'); for(const path of ['dist','.test-dist']) fs.rmSync(path,{recursive:true,force:true})\"",