d365fo-mcp 1.8.4 → 1.9.0

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 (45) hide show
  1. package/.github/copilot-instructions.md +1 -1
  2. package/dist/cli/copilotFiles.js +9 -7
  3. package/dist/prompts/systemInstructions.d.ts +1 -1
  4. package/dist/prompts/systemInstructions.js +2 -2
  5. package/dist/scripts/build-database.js +16 -1
  6. package/dist/scripts/build-fts.js +16 -1
  7. package/dist/scripts/extract-metadata.js +16 -1
  8. package/dist/server/toolSchemas/getWorkspaceInfo.js +3 -3
  9. package/dist/tools/codeGen.js +23 -21
  10. package/dist/tools/createD365File.js +8 -10
  11. package/dist/tools/createLabel.js +2 -1
  12. package/dist/tools/d365foFileOpSpecs.js +11 -4
  13. package/dist/tools/extensionStrategyAdvisor.js +26 -26
  14. package/dist/tools/generateSmartForm.js +10 -0
  15. package/dist/tools/generateSmartReport.js +11 -0
  16. package/dist/tools/generateSmartTable.js +11 -0
  17. package/dist/tools/getLabelInfo.js +31 -0
  18. package/dist/tools/modifyD365File.d.ts +1 -1
  19. package/dist/tools/modifyD365File.js +90 -8
  20. package/dist/tools/prefixDiagnostics.d.ts +45 -0
  21. package/dist/tools/prefixDiagnostics.js +105 -0
  22. package/dist/tools/search.js +3 -1
  23. package/dist/tools/toolHandler.js +128 -60
  24. package/dist/tools/validateFormPattern.d.ts +1 -1
  25. package/dist/tools/validateFormPattern.js +1 -1
  26. package/dist/tools/writeAnchorGuard.d.ts +41 -0
  27. package/dist/tools/writeAnchorGuard.js +44 -0
  28. package/dist/tools/xppKnowledge.js +4 -1
  29. package/dist/utils/configManager.d.ts +30 -0
  30. package/dist/utils/configManager.js +81 -0
  31. package/dist/utils/crossModelWriteGuard.d.ts +20 -3
  32. package/dist/utils/crossModelWriteGuard.js +27 -10
  33. package/dist/utils/indexStaleness.d.ts +7 -0
  34. package/dist/utils/indexStaleness.js +28 -5
  35. package/dist/utils/labelDiskCheck.d.ts +24 -0
  36. package/dist/utils/labelDiskCheck.js +88 -0
  37. package/dist/utils/loadEnv.d.ts +15 -0
  38. package/dist/utils/loadEnv.js +76 -1
  39. package/dist/utils/modelClassifier.d.ts +7 -4
  40. package/dist/utils/modelClassifier.js +12 -9
  41. package/dist/utils/modelPrefixInference.d.ts +25 -10
  42. package/dist/utils/modelPrefixInference.js +123 -21
  43. package/dist/workspace/contextSnapshot.d.ts +5 -3
  44. package/dist/workspace/contextSnapshot.js +22 -0
  45. package/package.json +1 -1
@@ -18,7 +18,7 @@ Call `get_workspace_info()` before doing anything with D365FO objects.
18
18
  |----------|--------|
19
19
  | Call fails | STOP. MCP server not connected. Ask user to start it. |
20
20
  | `⛔ CONFIGURATION PROBLEM` | STOP. Relay message. Wait for user. |
21
- | `✅ Configuration looks valid` | Note model name. Proceed. |
21
+ | No `⛔` in the response | Note the `Model` / `Prefix` lines. Proceed. |
22
22
 
23
23
  ## Terminal Prohibition
24
24
 
@@ -9,6 +9,10 @@
9
9
  * folder, or, when that is unknown, into a staging folder with a README
10
10
  * naming the two destinations.
11
11
  *
12
+ * The README belongs to the staging folder only. The solutions folder is the
13
+ * user's own projects directory, so the direct copy leaves nothing there but
14
+ * `.github\copilot-instructions.md` and says the rest on the console.
15
+ *
12
16
  * The README points at the one .mcp.json `mcpJsonNote()` writes in the data
13
17
  * root rather than copying it: the staging folder sits in that same data
14
18
  * root, so a second copy is only one more file to keep in sync.
@@ -22,7 +26,7 @@ import { dataRoot, paths, repoRoot } from './context.js';
22
26
  import { askConfirm, p } from './ui.js';
23
27
  /** The copy shipped with this installation — package root in both install modes. */
24
28
  const copilotSource = () => resolve(repoRoot, '.github', 'copilot-instructions.md');
25
- function writeCopilotSetupReadme(targetDir, opts) {
29
+ function writeCopilotSetupReadme(targetDir) {
26
30
  const readmePath = resolve(targetDir, 'README.md');
27
31
  const lines = [
28
32
  '# VS setup quick guide',
@@ -35,9 +39,7 @@ function writeCopilotSetupReadme(targetDir, opts) {
35
39
  ` - Generated copy: ${paths.mcpSuggestion}`,
36
40
  '',
37
41
  '2. Copy .github/copilot-instructions.md',
38
- opts.copilotAlreadyPlaced
39
- ? ' - Already placed here: .github\\copilot-instructions.md'
40
- : ' - Destination: a parent folder of your solution directories',
42
+ ' - Destination: a parent folder of your solution directories',
41
43
  ' - Why: provides mandatory D365FO tool-routing and safety rules for Copilot',
42
44
  '',
43
45
  '3. Restart Visual Studio after copying files.',
@@ -64,16 +66,16 @@ export async function maybePrepareCopilotInstructions(solutionsPath) {
64
66
  const targetDir = resolve(target, '.github');
65
67
  fs.mkdirSync(targetDir, { recursive: true });
66
68
  fs.copyFileSync(source, resolve(targetDir, 'copilot-instructions.md'));
67
- writeCopilotSetupReadme(target, { copilotAlreadyPlaced: true });
68
69
  p.log.success(`Prepared: ${resolve(targetDir, 'copilot-instructions.md')}`);
69
- p.log.success(`Prepared: ${resolve(target, 'README.md')}`);
70
+ p.log.info('Still to do: copy .mcp.json to %USERPROFILE%\\.mcp.json (or next to your .sln), then restart Visual Studio.\n' +
71
+ ` Generated copy: ${paths.mcpSuggestion}`);
70
72
  return;
71
73
  }
72
74
  const stageDir = resolve(dataRoot(), 'copilot-setup');
73
75
  const stageGitHubDir = resolve(stageDir, '.github');
74
76
  fs.mkdirSync(stageGitHubDir, { recursive: true });
75
77
  fs.copyFileSync(source, resolve(stageGitHubDir, 'copilot-instructions.md'));
76
- writeCopilotSetupReadme(stageDir, { copilotAlreadyPlaced: false });
78
+ writeCopilotSetupReadme(stageDir);
77
79
  if (wantsDirectCopy && !target) {
78
80
  p.log.warn('Solutions folder is empty, so files were prepared in the local staging folder instead.');
79
81
  }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Kept deliberately under 200 lines: the prompt holds only the tool decision
10
10
  * tree and hard prohibitions. Everything that is a rule about CODE lives in
11
- * the queryable knowledge base — get_xpp_knowledge (see the ID table below).
11
+ * the queryable knowledge base — get_knowledge (see the ID table below).
12
12
  */
13
13
  /**
14
14
  * Get the system instructions prompt definition
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Kept deliberately under 200 lines: the prompt holds only the tool decision
10
10
  * tree and hard prohibitions. Everything that is a rule about CODE lives in
11
- * the queryable knowledge base — get_xpp_knowledge (see the ID table below).
11
+ * the queryable knowledge base — get_knowledge (see the ID table below).
12
12
  */
13
13
  /**
14
14
  * Get the system instructions prompt definition
@@ -117,7 +117,7 @@ You are an AI assistant with access to D365FO MCP tools, assisting with Dynamics
117
117
  - No literal strings in \`Info()\`/\`error()\`/labels — use \`@Model:LabelId\` (reuse via \`labels(action="search")\` first)
118
118
  - Every public/protected member needs a meaningful \`/// <summary>\` (not "MyClass class.")
119
119
 
120
- **For full rules and examples call \`get_xpp_knowledge(id)\` BEFORE generating code:**
120
+ **For full rules and examples call \`get_knowledge(kind="knowledge", topic=<id>)\` BEFORE generating code:**
121
121
 
122
122
  | Knowledge ID | Covers |
123
123
  |---|---|
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -1,6 +1,6 @@
1
1
  // src/utils/loadEnv.ts
2
2
  import dotenv from "dotenv";
3
- import { existsSync as existsSync2 } from "fs";
3
+ import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
4
4
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
 
@@ -606,6 +606,12 @@ function defaultPathEnv(baseDir) {
606
606
 
607
607
  // src/utils/loadEnv.ts
608
608
  var PATH_VARS = ["DB_PATH", "LABELS_DB_PATH", "METADATA_PATH"];
609
+ var WRITE_POLICY_VARS = [
610
+ "D365FO_ALLOW_CROSS_MODEL_WRITE",
611
+ "D365FO_CROSS_MODEL_WRITE_MODELS"
612
+ ];
613
+ var writePolicySource = null;
614
+ var writePolicyStamp = "";
609
615
  function installRootFrom(callerDir) {
610
616
  let dir = resolve2(callerDir, "..");
611
617
  for (let up = 0; up < 3; up++) {
@@ -621,6 +627,15 @@ function loadEnv(callerImportMetaUrl) {
621
627
  const callerDir = dirname2(fileURLToPath(callerImportMetaUrl));
622
628
  const envPath = process.env.ENV_FILE ? resolve2(process.env.ENV_FILE) : resolve2(installRootFrom(callerDir), ".env");
623
629
  const fromRealEnv = new Set(Object.keys(process.env));
630
+ writePolicySource = {
631
+ envPath,
632
+ pinned: new Set(WRITE_POLICY_VARS.filter((k) => fromRealEnv.has(k)))
633
+ };
634
+ try {
635
+ writePolicyStamp = String(statSync(envPath).mtimeMs);
636
+ } catch {
637
+ writePolicyStamp = "-";
638
+ }
624
639
  const result = dotenv.config({ path: envPath, quiet: true });
625
640
  if (result.error && !process.env.ENV_FILE) {
626
641
  dotenv.config({ quiet: true });
@@ -5,13 +5,13 @@
5
5
  */
6
6
  export const getWorkspaceInfoTool = {
7
7
  name: 'get_workspace_info',
8
- description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix. Use projectName/projectPath params for solution switching. This is the authoritative source for target model — not search results.`,
8
+ description: `ALWAYS call FIRST at session start. Returns model name, package path, framework directory, project path, environment type, and EXTENSION_PREFIX. Flags placeholder model names and missing prefix. projectName/projectPath ONLY when the USER changed project. This is the authoritative source for target model — not search results.`,
9
9
  inputSchema: {
10
10
  type: 'object',
11
11
  properties: {
12
12
  projectName: {
13
13
  type: 'string',
14
- description: 'Preferred way to switch projects. Just the model name, e.g. "ContosoEDS" or "ContosoBank". The server resolves the full path from D365FO_SOLUTIONS_PATH automatically. Use this when the user says "switch to <project>" or opens a different solution.',
14
+ description: 'Only when the USER says "switch to <project>". Just the model name, e.g. "ContosoEDS"; path resolved from D365FO_SOLUTIONS_PATH. NOT a way to reach another model reads span every model already, writes stay in the workspace model.',
15
15
  },
16
16
  projectPath: {
17
17
  type: 'string',
@@ -20,7 +20,7 @@ export const getWorkspaceInfoTool = {
20
20
  diagnostics: {
21
21
  type: 'boolean',
22
22
  default: false,
23
- description: 'Include verbose diagnostic sections (suffix breakdown, stdio session/handshake dump). Use when debugging client-server connectivity.',
23
+ description: 'Include verbose sections (config sources, suffix, project paths, index scan, stdio handshake). Use when debugging config or connectivity.',
24
24
  },
25
25
  },
26
26
  required: [],
@@ -84,7 +84,7 @@ internal final class ${name}
84
84
  // D365FO Data Entity: ${name}Entity
85
85
  // ══════════════════════════════════════════════════════════════════
86
86
  // Data entities are AxDataEntityView XML objects (NOT X++ classes).
87
- // Use create_d365fo_file(objectType="view") or the VS designer.
87
+ // Use d365fo_file(action="create", objectType="view") or the VS designer.
88
88
  //
89
89
  // Key properties to set in XML:
90
90
  // PublicEntityName: "${name}" (OData singular name)
@@ -107,8 +107,8 @@ internal final class ${name}
107
107
  //
108
108
  // Workflow:
109
109
  // 1. get_object_info(objectType="data-entity", name="similar entity") → study structure
110
- // 2. generate_d365fo_xml(objectType="data-entity", ...) → preview XML
111
- // 3. create_d365fo_file(objectType="view", ...) → create file
110
+ // 2. generate_object(mode="scaffold", objectType="data-entity", ...) → preview XML
111
+ // 3. d365fo_file(action="create", objectType="view", ...) → create file
112
112
  // 4. After deployment: refresh entity list in Data Management workspace
113
113
  `,
114
114
  'batch-job': (name) => `
@@ -646,7 +646,7 @@ function menuItemXmlTemplate(name, itemType, targetObject) {
646
646
  function ssrsReportFullTemplate(name) {
647
647
  return `// ══════════════════════════════════════════════════════════════════
648
648
  // SSRS Report: ${name}
649
- // 5 objects required (use create_d365fo_file for each):
649
+ // 5 objects required (use d365fo_file(action="create") for each):
650
650
  // 1. ${name}TmpTable — TempDB table (objectType="table", tableType="TempDB")
651
651
  // 2. ${name}Contract — DataContract class (below)
652
652
  // 3. ${name}DP — Data Provider class (below)
@@ -1053,14 +1053,14 @@ public class ${name}Controller extends MenuFunction
1053
1053
  function dataEntityStagingTemplate(name) {
1054
1054
  return `// ══════════════════════════════════════════════════════════════════════════
1055
1055
  // Data Entity with Staging Table: ${name}
1056
- // 3 objects required (use create_d365fo_file for each):
1056
+ // 3 objects required (use d365fo_file(action="create") for each):
1057
1057
  // 1. ${name}StagingTable — TempDB staging table
1058
1058
  // 2. ${name}Entity — Data entity (AxDataEntityView)
1059
1059
  // 3. ${name}EntityService — Optional: AIF service class
1060
1060
  // ══════════════════════════════════════════════════════════════════════════
1061
1061
 
1062
1062
  // ── Object 1: Staging table ${name}Staging ─────────────────────────────────
1063
- // create_d365fo_file(objectType="table", objectName="${name}Staging", xmlContent=...)
1063
+ // d365fo_file(action="create", objectType="table", objectName="${name}Staging", xmlContent=...)
1064
1064
  // Set: TableType=TempDB (NOT RegularTable), TableGroup=Main
1065
1065
  // Fields mirror the entity's public fields exactly (same names, same EDTs)
1066
1066
 
@@ -1661,7 +1661,7 @@ public class ${name}Service
1661
1661
  }
1662
1662
  }
1663
1663
 
1664
- // ── 3. AOT objects (create via create_d365fo_file) ──────────────────────
1664
+ // ── 3. AOT objects (create via d365fo_file(action="create")) ────────────
1665
1665
  // Verify the result afterwards with get_object_info(objectType="service", name="${name}Service").
1666
1666
  // a) AxService XML (real schema: ServiceOperations / AxServiceOperation / Method):
1667
1667
  // <AxService xmlns:i="http://www.w3.org/2001/XMLSchema-instance">
@@ -1822,7 +1822,11 @@ export async function codeGenTool(request) {
1822
1822
  // Extension pattern — args.name is the BASE element; prefix becomes the infix
1823
1823
  // Grounding enforcement: extension patterns require proof that the AI looked at the
1824
1824
  // real codebase (via prepare_change) before generating extension code.
1825
- const groundingError = enforceGrounding(args.groundingToken, `generate_code(pattern="${args.pattern}", name="${args.name}")`, args.name);
1825
+ const groundingError = enforceGrounding(args.groundingToken,
1826
+ // The MCP-visible name, not the internal one: this string is echoed back
1827
+ // as the call the agent must repeat with a token, and `generate_code`
1828
+ // has not been a registered tool since the mega-tool consolidation.
1829
+ `generate_object(mode="pattern", pattern="${args.pattern}", name="${args.name}")`, args.name);
1826
1830
  if (groundingError)
1827
1831
  return groundingError;
1828
1832
  // ── form-datasource-extension / form-control-extension (3-param templates) ──
@@ -1924,20 +1928,18 @@ export async function codeGenTool(request) {
1924
1928
  text: `Generated ${args.pattern} template for "${displayName}":\n\n` +
1925
1929
  `\`\`\`xpp${code}\n\`\`\`\n\n` +
1926
1930
  `---\n\n` +
1927
- `${namingNote}\n\n` +
1931
+ namingNote +
1932
+ // Only the CoC signature rule earns a line here: guessing static vs
1933
+ // instance produces code that fails to compile. The rest was a menu
1934
+ // of optional follow-ups — and it named tools that do not exist
1935
+ // (`find_coc_extensions`) with argument forms analyze_code rejects
1936
+ // (positional, not `className`/`methodName`), so following it cost a
1937
+ // failed call before the agent could get anything useful.
1928
1938
  (args.pattern === 'class-extension'
1929
- ? `💡 **Next Steps (class-extension CoC workflow):**\n\n` +
1930
- `1. 🚨 Use \`get_method(include="signature", "${displayName}", "<methodName>")\` — **REQUIRED** to get the exact signature (static vs instance, return type, parameters) before writing any CoC method\n` +
1931
- `2. Use \`find_coc_extensions("${displayName}", "<methodName>")\` - See existing CoC wrappers for reference\n` +
1932
- `3. ✅ Use \`analyze_code(mode="implementations", "${displayName}", "<methodName>")\` - Get real implementation examples\n` +
1933
- `4. ✅ Use \`analyze_code(mode="api-usage", "<ClassName>")\` - See how to use D365FO APIs correctly\n\n` +
1934
- `⚠️ Never guess static vs instance — always use get_method(include="signature") first.`
1935
- : `💡 **Next Steps for Better Code Quality:**\n\n` +
1936
- `1. ✅ Use \`analyze_code(mode="patterns", "<scenario>")\` - Learn what D365FO classes are commonly used together\n` +
1937
- `2. ✅ Use \`analyze_code(mode="implementations", "${displayName}", "<methodName>")\` - Get real implementation examples\n` +
1938
- `3. ✅ Use \`analyze_code(mode="completeness", "${displayName}")\` - Check for missing common methods\n` +
1939
- `4. ✅ Use \`analyze_code(mode="api-usage", "<ClassName>")\` - See how to use D365FO APIs correctly\n\n` +
1940
- `These tools provide patterns from the actual codebase, not generic templates.`),
1939
+ ? `\n\n⚠️ Before writing any CoC method call \`get_method(include="signature", className="${displayName}", methodName="<methodName>")\` — ` +
1940
+ `never guess static vs instance, the return type or the parameter list. ` +
1941
+ `Existing wrappers: \`extension_info(mode="coc", target="${displayName}")\`.`
1942
+ : ``),
1941
1943
  },
1942
1944
  ],
1943
1945
  };
@@ -3806,7 +3806,8 @@ export async function handleCreateD365File(request, context) {
3806
3806
  objectName: args.objectName,
3807
3807
  objectType: args.objectType,
3808
3808
  owningModel: actualModelName,
3809
- activeModel: getConfigManager().getModelName() ?? '',
3809
+ activeModel: getConfigManager().getWriteAnchorModel() ?? '',
3810
+ toolSwitchedModel: getConfigManager().getToolProjectSwitch()?.forcedModel ?? null,
3810
3811
  action: 'create',
3811
3812
  });
3812
3813
  if (crossModelCreateRefusal) {
@@ -4711,16 +4712,13 @@ export async function handleCreateD365File(request, context) {
4711
4712
  `\nUntil resolved, add the file manually in Visual Studio: right-click project → Add Existing Item → ${normalizedFullPath}\n`;
4712
4713
  }
4713
4714
  }
4714
- // Build success message
4715
+ // Only the step the AGENT can take. "Reload the project in VS / refresh the
4716
+ // AOT" is a human's UI chore, repeated on every object of a feature; when it
4717
+ // matters (addToProject failed, no projectPath) `projectMessage` above
4718
+ // already says so, in that specific case.
4715
4719
  const nextSteps = args.addToProject
4716
- ? `Next steps:\n` +
4717
- `1. Reload project in Visual Studio (or close/reopen solution)\n` +
4718
- `2. Build the project to synchronize the object\n` +
4719
- `3. Refresh AOT in Visual Studio to see the new object\n`
4720
- : `Next steps:\n` +
4721
- `1. Add the file to your Visual Studio project (.rnrproj)\n` +
4722
- `2. Build the project to synchronize the object\n` +
4723
- `3. Refresh AOT in Visual Studio to see the new object\n`;
4720
+ ? `Next: build_d365fo_project to synchronize the object.\n`
4721
+ : `Next: add the file to your .rnrproj, then build_d365fo_project to synchronize the object.\n`;
4724
4722
  // Record the freshly-created file for non-git undo (see the bridge paths above).
4725
4723
  if (!fileExisted) {
4726
4724
  recordCreatedArtifact({
@@ -456,7 +456,8 @@ export async function createLabelTool(request, context) {
456
456
  objectType: 'label',
457
457
  owningModel: model,
458
458
  owningPackage: resolvedPackageName,
459
- activeModel: configManager.getModelName() ?? '',
459
+ activeModel: configManager.getWriteAnchorModel() ?? '',
460
+ toolSwitchedModel: configManager.getToolProjectSwitch()?.forcedModel ?? null,
460
461
  action: 'create',
461
462
  });
462
463
  if (crossModelLabelRefusal) {
@@ -73,7 +73,11 @@ export const D365FO_FILE_PARAM_SPECS = {
73
73
  fieldMandatory: { type: 'boolean', description: 'Mark the field Mandatory=Yes.' },
74
74
  fieldLabel: { type: 'string', description: 'Field label.' },
75
75
  fieldHelpText: { type: 'string', description: 'Field help text.' },
76
- fieldEnumType: { type: 'string', description: 'Enum name to set on an enum-typed field.' },
76
+ fieldEnumType: {
77
+ type: 'string',
78
+ description: 'Enum name for an enum-typed field. On add-field this is all an enum field needs — ' +
79
+ 'it writes AxTableFieldEnum + EnumType, no EDT.',
80
+ },
77
81
  fieldStringSize: { type: 'string', description: 'String size to set on a string-typed field.' },
78
82
  dataField: {
79
83
  type: 'string',
@@ -267,9 +271,12 @@ export const D365FO_FILE_OP_SPECS = {
267
271
  },
268
272
  'add-field': {
269
273
  required: ['fieldName'],
270
- optional: ['fieldType', 'fieldBaseType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
271
- mutationOneOf: ['fieldType', 'dataField'],
272
- note: 'Table/table-extension: fieldType (EDT) is REQUIRED. data-entity-extension: pass dataField AND ' +
274
+ optional: ['fieldType', 'fieldBaseType', 'fieldEnumType', 'fieldMandatory', 'fieldLabel', 'dataField', 'dataSource', 'fieldGroupName'],
275
+ mutationOneOf: ['fieldType', 'fieldEnumType', 'dataField'],
276
+ note: 'Enum field: pass fieldEnumType="<enum name>" and NO fieldType an enum-typed table field ' +
277
+ 'is an AxTableFieldEnum with an EnumType and needs no EDT. (fieldType is the EDT name here, ' +
278
+ 'never an XML element name like "AxTableFieldEnum".) ' +
279
+ 'Table/table-extension: otherwise fieldType (EDT) is REQUIRED. data-entity-extension: pass dataField AND ' +
273
280
  'dataSource instead — BOTH, or nothing is written; a mapped field has no EDT of its own, it points ' +
274
281
  'at dataField on the entity data source dataSource. fieldGroupName is optional and only applies to ' +
275
282
  'a data-entity-extension: it appends the field to that BASE-entity field group (shipped extensions ' +
@@ -46,9 +46,9 @@ const STRATEGY_RULES = [
46
46
  { mechanism: 'Form data source validateWrite()', when: 'Validation is UI-specific and should NOT apply to service/entity imports' },
47
47
  ],
48
48
  nextSteps: [
49
- 'analyze_extension_points("TableName") — check available events and existing extensions',
50
- 'find_event_handlers("TableName") — see if someone already handles the same event',
51
- 'generate_code(pattern="event-handler") — generate the handler skeleton',
49
+ 'extension_info(mode="points", target="TableName") — available events and existing extensions',
50
+ 'extension_info(mode="events", target="TableName") — see if someone already handles the same event',
51
+ 'generate_object(mode="pattern", pattern="event-handler", name="TableName") — handler skeleton',
52
52
  ],
53
53
  antiPatterns: [
54
54
  { wrong: 'Business Event', why: 'Business Events are for outbound notifications, not validation logic' },
@@ -73,8 +73,8 @@ const STRATEGY_RULES = [
73
73
  { mechanism: 'Form data source initValue()', when: 'Default depends on form context (e.g. header record of a lines form)' },
74
74
  ],
75
75
  nextSteps: [
76
- 'analyze_extension_points("TableName") — check initValue availability',
77
- 'get_method(include="signature", "TableName", "initValue", includeCocTemplate: true) — get CoC template',
76
+ 'extension_info(mode="points", target="TableName") — check initValue availability',
77
+ 'get_method(include="signature", className="TableName", methodName="initValue") — exact signature for the CoC wrapper',
78
78
  ],
79
79
  antiPatterns: [
80
80
  { wrong: 'Overriding insert()', why: 'insert() is for persistence — defaults belong in initValue()' },
@@ -110,9 +110,9 @@ const STRATEGY_RULES = [
110
110
  { mechanism: 'CoC on table.modifiedFieldValue()', when: 'You also need the previous value to decide what to do' },
111
111
  ],
112
112
  nextSteps: [
113
- 'analyze_extension_points("TableName") — confirm modifiedField is CoC-eligible and see existing extensions',
114
- 'get_method(include="signature", "TableName", "modifiedField", includeCocTemplate: true) — get the CoC skeleton',
115
- 'find_coc_extensions("TableName", "modifiedField") — check for existing wrappers',
113
+ 'extension_info(mode="points", target="TableName") — confirm modifiedField is CoC-eligible and see existing extensions',
114
+ 'get_method(include="signature", className="TableName", methodName="modifiedField") — exact signature for the CoC wrapper',
115
+ 'extension_info(mode="coc", target="TableName", method="modifiedField") — check for existing wrappers',
116
116
  ],
117
117
  antiPatterns: [
118
118
  { wrong: 'CoC on initValue()', why: 'initValue fires only at record creation — it will NOT run when the user later changes the field' },
@@ -141,9 +141,9 @@ const STRATEGY_RULES = [
141
141
  { mechanism: 'Replaceable method', when: 'Method is marked [Replaceable] — you can fully replace it (rare in standard app)' },
142
142
  ],
143
143
  nextSteps: [
144
- 'analyze_extension_points("ClassName") — see CoC-eligible methods and delegates',
145
- 'get_method(include="signature", "ClassName", "methodName", includeCocTemplate: true) — get exact CoC skeleton',
146
- 'find_coc_extensions("ClassName", "methodName") — check for existing CoC wrappers',
144
+ 'extension_info(mode="points", target="ClassName") — see CoC-eligible methods and delegates',
145
+ 'get_method(include="signature", className="ClassName", methodName="methodName") — exact signature for the CoC wrapper',
146
+ 'extension_info(mode="coc", target="ClassName", method="methodName") — check for existing CoC wrappers',
147
147
  ],
148
148
  antiPatterns: [
149
149
  { wrong: 'Copy-paste the entire class', why: 'Over-layering defeats the purpose of extensions and blocks upgrades' },
@@ -172,8 +172,8 @@ const STRATEGY_RULES = [
172
172
  { mechanism: 'Dual-write', when: 'Real-time bidirectional sync with Dataverse is needed' },
173
173
  ],
174
174
  nextSteps: [
175
- 'get_knowledge(kind="knowledge", "business events") — learn the pattern',
176
- 'generate_code(pattern="business-event", name="MyEvent") — generate skeleton',
175
+ 'get_knowledge(kind="knowledge", topic="business events") — learn the pattern',
176
+ 'generate_object(mode="pattern", pattern="business-event", name="MyEvent") — generate skeleton',
177
177
  ],
178
178
  antiPatterns: [
179
179
  { wrong: 'CoC calling HttpClient', why: 'Synchronous HTTP in a transaction blocks the user and risks timeout/rollback' },
@@ -203,9 +203,9 @@ const STRATEGY_RULES = [
203
203
  { mechanism: 'Composite entity', when: 'Header + lines structure needs to be imported as a document' },
204
204
  ],
205
205
  nextSteps: [
206
- 'get_knowledge(kind="knowledge", "data-management-framework") — learn DMF patterns',
207
- 'search("MyTable", "data-entity") — check if an entity already exists',
208
- 'generate_code(pattern="data-entity", name="MyEntity") — generate entity skeleton',
206
+ 'get_knowledge(kind="knowledge", topic="data-management-framework") — learn DMF patterns',
207
+ 'search(query="MyTable", type="data-entity") — check if an entity already exists',
208
+ 'generate_object(mode="pattern", pattern="data-entity", name="MyEntity") — generate entity skeleton',
209
209
  ],
210
210
  antiPatterns: [
211
211
  { wrong: 'Direct table insert via custom endpoint', why: 'Bypasses validation, number sequences, and event handlers' },
@@ -234,7 +234,7 @@ const STRATEGY_RULES = [
234
234
  ],
235
235
  nextSteps: [
236
236
  'get_object_info(objectType="form", name="FormName", options={searchControl:"General"}) — find exact control names and hierarchy',
237
- 'analyze_extension_points("FormName") — check form extension points',
237
+ 'extension_info(mode="points", target="FormName", objectType="form") — check form extension points',
238
238
  'd365fo_file(action="create", objectType="form-extension") — create the extension',
239
239
  ],
240
240
  antiPatterns: [
@@ -265,8 +265,8 @@ const STRATEGY_RULES = [
265
265
  ],
266
266
  nextSteps: [
267
267
  'get_object_info(objectType="report", name="ReportName") — inspect existing report structure',
268
- 'get_knowledge(kind="knowledge", "ssrs-reports") — patterns for SSRS',
269
- 'generate_smart(objectType="report", name="MyReport") — generate full SSRS stack',
268
+ 'get_knowledge(kind="knowledge", topic="ssrs-reports") — patterns for SSRS',
269
+ 'generate_object(mode="scaffold", objectType="report", name="MyReport") — generate full SSRS stack',
270
270
  ],
271
271
  antiPatterns: [
272
272
  { wrong: 'Business Event for document delivery', why: 'Business Events send notifications, not formatted documents' },
@@ -290,8 +290,8 @@ const STRATEGY_RULES = [
290
290
  { mechanism: 'Custom counter table', when: 'Simple auto-increment without legal entity scope or configurable format (rare — prefer the framework)' },
291
291
  ],
292
292
  nextSteps: [
293
- 'get_knowledge(kind="knowledge", "number-sequences") — full pattern reference',
294
- 'generate_code(pattern="number-seq-handler") — generate skeleton',
293
+ 'get_knowledge(kind="knowledge", topic="number-sequences") — full pattern reference',
294
+ 'generate_object(mode="pattern", pattern="number-seq-handler", name="MyModule") — generate skeleton',
295
295
  ],
296
296
  antiPatterns: [
297
297
  { wrong: 'Identity column / RecId as business number', why: 'RecId is internal — users need formatted, gapless (or configurable) business numbers' },
@@ -318,8 +318,8 @@ const STRATEGY_RULES = [
318
318
  { mechanism: 'Table permission framework override', when: 'Granting DML access without a menu item entry point (rare)' },
319
319
  ],
320
320
  nextSteps: [
321
- 'get_knowledge(kind="knowledge", "security-privileges-duties") — security pattern reference',
322
- 'security_info(mode="coverage", "ObjectName") — check existing security chain',
321
+ 'get_knowledge(kind="knowledge", topic="security-privileges-duties") — security pattern reference',
322
+ 'security_info(mode="coverage", objectName="ObjectName") — check existing security chain',
323
323
  'd365fo_file(action="create", objectType="security-privilege") — create privilege',
324
324
  ],
325
325
  antiPatterns: [
@@ -346,9 +346,9 @@ const STRATEGY_RULES = [
346
346
  { mechanism: 'Business Event + external processor', when: 'Processing should happen outside D365FO (e.g. Azure Function)' },
347
347
  ],
348
348
  nextSteps: [
349
- 'get_knowledge(kind="knowledge", "sysoperation") — SysOperation patterns',
350
- 'generate_code(pattern="sysoperation", name="MyProcess") — generate SysOperation skeleton',
351
- 'generate_code(pattern="batch-job", name="MyBatch") — generate RunBaseBatch skeleton',
349
+ 'get_knowledge(kind="knowledge", topic="sysoperation") — SysOperation patterns',
350
+ 'generate_object(mode="pattern", pattern="sysoperation", name="MyProcess") — generate SysOperation skeleton',
351
+ 'generate_object(mode="pattern", pattern="batch-job", name="MyBatch") — generate RunBaseBatch skeleton',
352
352
  ],
353
353
  antiPatterns: [
354
354
  { wrong: 'Thread.Sleep / while-polling in batch', why: 'Use batch recurrence and alerts — polling wastes AOS resources' },
@@ -21,6 +21,7 @@ import { methodStubsForPattern, injectMethodStubs } from '../knowledge/formPatte
21
21
  import { findBaseFormXml } from './modifyD365File.js';
22
22
  import { getFieldControlMap, getTableTitleField } from '../utils/fieldControlTypes.js';
23
23
  import { lookupSymbolNocase } from '../utils/symbolLookup.js';
24
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
24
25
  /**
25
26
  * Symbol types a form datasource may bind to. Views are indexed as 'view'
26
27
  * (see symbolIndex.indexViews) and are legal in <Table> just like tables.
@@ -560,6 +561,15 @@ export async function handleGenerateSmartForm(args, symbolIndex) {
560
561
  if (finalName !== name) {
561
562
  console.log(`[generateSmartForm] Applied naming: ${name} → ${finalName}`);
562
563
  }
564
+ // See generateSmartTable: the resolved model follows the ACTIVE project, the
565
+ // write anchor does not. Checked before the first byte is written.
566
+ const anchorRefusal = scaffoldWriteRefusalResult({
567
+ objectName: finalName,
568
+ objectType: 'form',
569
+ targetModel: resolvedModel,
570
+ });
571
+ if (anchorRefusal)
572
+ return anchorRefusal;
563
573
  // Generate XML: clone an existing form (preferred) or build from a template.
564
574
  // Without an explicit pattern, default to the majority pattern mined from
565
575
  // standard models (property_stats), falling back to SimpleList.
@@ -32,6 +32,7 @@ import { resolveObjectPrefix, applyObjectPrefix, getObjectSuffix, applyObjectSuf
32
32
  import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
33
33
  import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
34
34
  import { canonicalSymbolName, lookupSymbolNocase } from '../utils/symbolLookup.js';
35
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
35
36
  export const generateSmartReportTool = {
36
37
  name: 'generate_smart_report',
37
38
  description: `🎨 AI-driven SSRS report generation — creates up to 7 D365FO objects in one call.
@@ -221,6 +222,16 @@ export async function handleGenerateSmartReport(args, symbolIndex, bridge) {
221
222
  finalName = applyObjectSuffix(finalName, objectSuffix);
222
223
  if (finalName !== name)
223
224
  log(`Applied naming: ${name} → ${finalName}`);
225
+ // See generateSmartTable: the resolved model follows the ACTIVE project, the
226
+ // write anchor does not. A report scaffold writes several objects at once, so
227
+ // this is checked before any of them exists.
228
+ const anchorRefusal = scaffoldWriteRefusalResult({
229
+ objectName: finalName,
230
+ objectType: 'report',
231
+ targetModel: resolvedModel,
232
+ });
233
+ if (anchorRefusal)
234
+ return anchorRefusal;
224
235
  // Derived object names
225
236
  const tmpTableName = `${finalName}Tmp`;
226
237
  const contractClassName = `${finalName}Contract`;
@@ -13,6 +13,7 @@ import { ProjectFileManager } from './createD365File.js';
13
13
  import { extractModelFromProject, findProjectInSolution } from '../utils/projectUtils.js';
14
14
  import { normalizeD365Xml } from '../utils/d365XmlNormalizer.js';
15
15
  import { lookupSymbolNocase } from '../utils/symbolLookup.js';
16
+ import { scaffoldWriteRefusalResult } from './writeAnchorGuard.js';
16
17
  export const generateSmartTableTool = {
17
18
  name: 'generate_smart_table',
18
19
  description: 'Generate AxTable XML with AI-driven field/index/relation suggestions based on indexed patterns. Can copy structure from existing tables, analyze table group patterns, or use field hints.',
@@ -559,6 +560,16 @@ export async function handleGenerateSmartTable(args, symbolIndex, bridge) {
559
560
  if (finalName !== name) {
560
561
  console.log(`[generateSmartTable] Applied naming: ${name} → ${finalName}`);
561
562
  }
563
+ // Where this scaffold would land, checked before anything is written. The
564
+ // model above comes from the ACTIVE project, which a get_workspace_info switch
565
+ // moves; writes stay anchored to the model the workspace resolved on its own.
566
+ const anchorRefusal = scaffoldWriteRefusalResult({
567
+ objectName: finalName,
568
+ objectType: 'table',
569
+ targetModel: resolvedModel,
570
+ });
571
+ if (anchorRefusal)
572
+ return anchorRefusal;
562
573
  // Generate standard methods (find, exist) based on primary key fields
563
574
  const generatedMethods = [];
564
575
  if (requestedMethods && requestedMethods.length > 0) {