Skip to content

Commit 72437af

Browse files
peopleworksclaude
andcommitted
Add xaflogic agents: context files any AI agent can use
The tool could describe an XAF application but had nowhere useful to put the description. This adds the output that needs no infrastructure at all: AGENTS.md, CLAUDE.md and .github/copilot-instructions.md, written next to the analyzed solution. No account, no key, no server. The output is tiered, and that is the design rather than an optimization. AGENTS.md is prepended to every request an agent makes in the repository, so its cost is paid on every question forever. Putting 70 KB of entity detail there would crowd out the thing the user actually asked. So the index stays around 11 KB and the detail goes to .xaflogic/, which agents open only when a question needs it. The most valuable section is the smallest. Ground rules state which ORM this application uses and which APIs therefore do not exist in it; that the inventories are complete, so an entity that does not appear genuinely does not exist rather than living in an unread file; and that some behavior lives in the Model Editor and cannot be inferred from C# at all. Those paragraphs address the specific ways agents are confidently wrong about unfamiliar XAF codebases. Conventions are inferred from the codebase itself -- namespaces, folder layout, base classes, whether associations are named, whether validation is attributes or code -- so suggested code matches the surrounding style instead of a tutorial. Criteria expressions are quoted verbatim from the source, because XAF's criteria language is neither SQL nor C# and examples teach it better than description. Generated text is written between markers. A CLAUDE.md someone has been keeping notes in survives regeneration, and repeated runs are byte-identical: the first implementation grew the file by one blank line per run, which in a repository becomes a spurious diff every time and trains people to stop reading them. IDocumentationSink makes the destination a choice. PeopleWorks Copilot is now one implementation behind that interface, with its upload logic unchanged. Fixes an extraction bug found by reading the generated output against a real 19-entity application: ModuleAnalyzer accepted any invocation whose expression contained "Add", so AdditionalExportedTypes.Add(typeof(Cliente)) was reported as a required XAF module. The list of twelve genuine modules had nine business entities and six framework base types mixed into it. Being wrong under an authoritative heading is worse than being absent, and this would have shipped into every generated AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6606fa9 commit 72437af

10 files changed

Lines changed: 1546 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,39 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`xaflogic agents`** — writes `AGENTS.md`, `CLAUDE.md` and `.github/copilot-instructions.md`
13+
so any AI coding agent understands the analyzed application. No account, key or server.
14+
- Output is **tiered**: a compact index (~11 KB) that agents load on every request, and detail
15+
files in `.xaflogic/` (~70 KB) they open only when a question needs them. An `AGENTS.md` is
16+
prepended to every conversation, so putting the full documentation there would consume the
17+
context the user's actual question needs.
18+
- The index leads with **ground rules**: which ORM this application uses and which APIs
19+
therefore do not exist in it, that the inventories are complete so an absent entity is
20+
genuinely absent, and that some behavior lives in the Model Editor rather than in C#.
21+
- **Conventions are inferred from the codebase** — namespaces, folder layout, base classes, how
22+
associations and validation are written — so generated code matches the surrounding style
23+
instead of a generic tutorial.
24+
- Includes **real criteria expressions** taken from the source. XAF's criteria language is
25+
neither SQL nor C#, and worked examples teach the dialect better than a description of it.
26+
- Generated text is written between markers: anything you wrote by hand is preserved, and
27+
regenerating produces byte-identical output when nothing has changed.
28+
- `IDocumentationSink`, making a publishing target something the caller chooses. PeopleWorks
29+
Copilot is now one implementation of it rather than the destination the tool is built around.
30+
31+
### Fixed
32+
33+
- `ModuleAnalyzer` reported business entities as required XAF modules. It accepted any invocation
34+
whose expression contained `Add`, so `AdditionalExportedTypes.Add(typeof(Cliente))` was read as
35+
a module dependency. On a real project this listed nine entities and six framework base types
36+
among twelve genuine modules. It now matches the target collection, and
37+
`AdditionalExportedTypes` feeds the registered-types list where it belongs.
38+
1039
### Planned
1140

12-
- `AGENTS.md` / `CLAUDE.md` / `copilot-instructions.md` output — no infrastructure required
1341
- MCP server, so any agent can query an XAF codebase live
1442
- Agent skill installable from this repository, alongside DevExpress's own `dx-xaf` plugin
15-
- `IDocumentationSink` abstraction, making PeopleWorks Copilot one target among several
1643
- AI provider abstraction (OpenAI, Azure OpenAI, Anthropic, Ollama) for `--enrich`
1744
- xUnit test suite over a synthetic XAF fixture that needs no DevExpress reference
1845
- Optional DevExpress ground-truth catalog, generated locally by licensees

README.md

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,36 @@ tool never links against DevExpress assemblies:
5656
```bash
5757
dotnet tool install -g XafLogicExplainer.Cli
5858

59-
# Extract, writing Markdown + JSON into .xaflogic-output/
60-
xaflogic extract --project "C:\MySolution\MyApp.Module"
59+
xaflogic agents --project "C:\MySolution\MyApp.Module"
6160
```
6261

63-
Documentation is generated in **English or Spanish** (`--lang en|es`).
62+
That writes `AGENTS.md`, `CLAUDE.md` and `.github/copilot-instructions.md` at your solution root.
63+
No account, no API key, no server. Your agent understands the application on its next question.
64+
65+
### What it writes, and why it is split in two
66+
67+
`AGENTS.md` is prepended to *every* request an agent makes in the repository, so its cost is paid
68+
forever. Dumping 70 KB of entity detail there would crowd out the actual question. So the output is
69+
tiered:
70+
71+
| | | |
72+
| --- | --- | --- |
73+
| `AGENTS.md` | ~11 KB | Always loaded: ground rules, complete inventories, conventions, recipes |
74+
| `.xaflogic/*.md` | ~70 KB | Opened on demand: full properties, handler code, rule messages, `.xafml` |
75+
76+
The most valuable part is the smallest. `AGENTS.md` opens with **ground rules** — that this
77+
application uses XPO and never EF Core, that the inventories are *complete* so anything absent
78+
genuinely does not exist, and that some behavior lives in the Model Editor rather than in C#. Those
79+
few paragraphs stop most of the confident invention agents produce about unfamiliar XAF codebases.
80+
81+
Existing files are never clobbered: generated text lives between markers, anything you wrote by
82+
hand is preserved, and regenerating is byte-identical when nothing changed.
6483

6584
### Commands
6685

6786
| Command | What it does |
6887
| --- | --- |
88+
| `agents` | **Write `AGENTS.md` / `CLAUDE.md` / Copilot instructions for your agent** |
6989
| `extract` | Read the project, write Markdown + JSON locally |
7090
| `diff` | Compare against the previous extraction and report what changed |
7191
| `status` | Show the change-detection hash and whether a re-extract is needed |
@@ -75,6 +95,8 @@ Documentation is generated in **English or Spanish** (`--lang en|es`).
7595
| `config` | Set defaults in `~/.xaflogic/config.json` |
7696
| `projects` | Manage several XAF projects; most commands accept `--all` |
7797

98+
Documentation is generated in **English or Spanish** (`--lang en|es`).
99+
78100
Useful flags: `--orm auto\|xpo\|efcore`, `--lang en\|es`, `--enrich` (AI-generated business-logic
79101
summaries per controller and action), `--force`, `--all`.
80102

@@ -93,15 +115,14 @@ applications. The agent-facing surface is what is landing now, in the open.
93115
|| Incremental change detection, diff reports, multi-project, watch mode |
94116
|| AI enrichment of controllers and actions (`--enrich`) |
95117
|| Blazor in-app help panel |
96-
| 🚧 | **`AGENTS.md` / `CLAUDE.md` output** — zero infrastructure, works for everyone |
118+
|| **`AGENTS.md` / `CLAUDE.md` / Copilot instructions** — zero infrastructure, works for everyone |
119+
|| Pluggable publishing targets (`IDocumentationSink`) |
97120
| 🚧 | **MCP server** — let any agent query your XAF app live |
98121
| 🚧 | **Claude Code / Copilot / Cursor skill**, installable from this repo |
99-
| 🚧 | Pluggable publishing targets (`IDocumentationSink`) |
100122
| 🚧 | xUnit test suite over a synthetic XAF fixture |
101123

102-
Today the only remote publishing target is PeopleWorks Copilot, which is where this tool grew up.
103-
That is being generalized: it becomes one sink among several, and the interesting outputs
104-
(`AGENTS.md`, MCP) need no server at all.
124+
PeopleWorks Copilot, where this tool grew up, is now one sink among several rather than the
125+
destination everything was built around. The outputs that matter most need no server at all.
105126

106127
## Repository layout
107128

src/XafLogicExplainer.Cli/Program.cs

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using XafLogicExplainer.Core.Diff;
1111
using XafLogicExplainer.Core.Generators;
1212
using XafLogicExplainer.Core.Hashing;
13+
using XafLogicExplainer.Core.Sinks;
1314
using Microsoft.Extensions.AI;
1415
using OpenAI;
1516
using XafLogicExplainer.Core.Models;
@@ -21,7 +22,7 @@
2122
if (args.Length == 0 || args.Contains("--help") || args.Contains("-h"))
2223
{
2324
AnsiConsole.Write(new FigletText("XAF Logic").Color(Color.Blue));
24-
AnsiConsole.MarkupLine("[grey]Extract, document, and sync XAF project logic to PeopleWorks Copilot[/]");
25+
AnsiConsole.MarkupLine("[grey]Teach your AI coding agent what your XAF application actually does[/]");
2526
AnsiConsole.WriteLine();
2627
}
2728

@@ -1370,10 +1371,204 @@ await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Comparing sna
13701371

13711372
rootCommand.AddCommand(diffCommand);
13721373

1374+
// ============================================================
1375+
// COMMAND: agents
1376+
// ============================================================
1377+
var agentsCommand = new Command(
1378+
"agents",
1379+
"Generate AGENTS.md, CLAUDE.md and Copilot instructions so AI agents understand this XAF app");
1380+
1381+
var agentsOutputOption = new Option<string?>(
1382+
"--output",
1383+
"Where to write (default: the solution or repository root above the module)");
1384+
var agentsOnlyOption = new Option<string?>(
1385+
"--only",
1386+
"Limit the files written: agents, claude, copilot (comma-separated)");
1387+
1388+
agentsCommand.AddOption(projectPathOption);
1389+
agentsCommand.AddOption(languageOption);
1390+
agentsCommand.AddOption(forceOption);
1391+
agentsCommand.AddOption(ormOption);
1392+
agentsCommand.AddOption(allOption);
1393+
agentsCommand.AddOption(enrichOption);
1394+
agentsCommand.AddOption(agentsOutputOption);
1395+
agentsCommand.AddOption(agentsOnlyOption);
1396+
1397+
// InvocationContext rather than a typed handler: SetHandler tops out at eight parameters, and
1398+
// this command already sits at that boundary. sync and watch resolve options the same way.
1399+
agentsCommand.SetHandler(async (context) =>
1400+
{
1401+
var agentsProjectPath = context.ParseResult.GetValueForOption(projectPathOption);
1402+
var agentsLanguage = context.ParseResult.GetValueForOption(languageOption);
1403+
var agentsForce = context.ParseResult.GetValueForOption(forceOption);
1404+
var agentsOrm = context.ParseResult.GetValueForOption(ormOption);
1405+
var agentsAll = context.ParseResult.GetValueForOption(allOption);
1406+
var agentsEnrich = context.ParseResult.GetValueForOption(enrichOption);
1407+
var agentsOutput = context.ParseResult.GetValueForOption(agentsOutputOption);
1408+
var agentsOnly = context.ParseResult.GetValueForOption(agentsOnlyOption);
1409+
1410+
var agentsConfig = ConfigHelper.Load();
1411+
var agentsOptions = ParseAgentTargets(agentsOnly, agentsOutput);
1412+
1413+
if (agentsAll)
1414+
{
1415+
if (agentsConfig.Projects.Count == 0)
1416+
{
1417+
AnsiConsole.MarkupLine("[red]✗[/] No projects configured. Add one with: [cyan]xaflogic projects add[/]");
1418+
return;
1419+
}
1420+
1421+
foreach (var configured in agentsConfig.Projects)
1422+
{
1423+
AnsiConsole.WriteLine();
1424+
AnsiConsole.MarkupLine($"[bold blue]▸ {Markup.Escape(configured.Name)}[/]");
1425+
1426+
await GenerateAgentFiles(
1427+
configured.ProjectPath,
1428+
configured.Language ?? agentsLanguage ?? agentsConfig.Language ?? "es",
1429+
configured.Orm ?? agentsOrm,
1430+
agentsForce,
1431+
agentsEnrich,
1432+
agentsOptions,
1433+
agentsConfig);
1434+
}
1435+
1436+
return;
1437+
}
1438+
1439+
var singleProjectPath = agentsProjectPath ?? agentsConfig.ProjectPath;
1440+
1441+
if (string.IsNullOrEmpty(singleProjectPath) || !Directory.Exists(singleProjectPath))
1442+
{
1443+
AnsiConsole.MarkupLine("[red]✗[/] --project is required. Set it with: [cyan]xaflogic config --project <path>[/]");
1444+
return;
1445+
}
1446+
1447+
await GenerateAgentFiles(
1448+
singleProjectPath,
1449+
agentsLanguage ?? agentsConfig.Language ?? "es",
1450+
agentsOrm ?? agentsConfig.Orm,
1451+
agentsForce,
1452+
agentsEnrich,
1453+
agentsOptions,
1454+
agentsConfig);
1455+
});
1456+
1457+
rootCommand.AddCommand(agentsCommand);
1458+
13731459
// ============================================================
13741460
// HELPERS
13751461
// ============================================================
13761462

1463+
// Turns the --only and --output values into sink options.
1464+
static AgentFilesOptions ParseAgentTargets(string? only, string? output)
1465+
{
1466+
if (string.IsNullOrWhiteSpace(only))
1467+
return new AgentFilesOptions { OutputRoot = output };
1468+
1469+
var requested = only
1470+
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
1471+
.Select(t => t.ToLowerInvariant())
1472+
.ToHashSet();
1473+
1474+
return new AgentFilesOptions
1475+
{
1476+
OutputRoot = output,
1477+
WriteAgentsMd = requested.Contains("agents"),
1478+
WriteClaudeMd = requested.Contains("claude"),
1479+
WriteCopilotInstructions = requested.Contains("copilot"),
1480+
};
1481+
}
1482+
1483+
// Extracts one project and writes the agent-facing context files.
1484+
static async Task GenerateAgentFiles(
1485+
string projectPath,
1486+
string language,
1487+
string? orm,
1488+
bool force,
1489+
bool enrich,
1490+
AgentFilesOptions options,
1491+
CliConfig config)
1492+
{
1493+
if (!Directory.Exists(projectPath))
1494+
{
1495+
AnsiConsole.MarkupLine($"[red]✗[/] Not found: {Markup.Escape(projectPath)}");
1496+
return;
1497+
}
1498+
1499+
var agentHashCalc = new ProjectHashCalculator();
1500+
if (!force && !agentHashCalc.HasChanged(projectPath))
1501+
{
1502+
AnsiConsole.MarkupLine("[yellow]⊘[/] No source changes since the last run. Use [cyan]--force[/] to regenerate anyway.");
1503+
return;
1504+
}
1505+
1506+
ExtractedProject agentProject = null!;
1507+
await AnsiConsole.Status().Spinner(Spinner.Known.Dots).StartAsync("Reading the application...", async ctx =>
1508+
{
1509+
var agentExtractor = new LogicExtractor();
1510+
agentProject = agentExtractor.ExtractFromSourceDirectory(projectPath, BuildExtractionOptions(language, orm));
1511+
await Task.CompletedTask;
1512+
});
1513+
1514+
if (enrich)
1515+
{
1516+
await EnrichWithAi(agentProject, config, language);
1517+
}
1518+
1519+
var agentGenerator = new MarkdownDocumentationGenerator(language);
1520+
var agentSections = agentGenerator.GenerateSections(agentProject);
1521+
1522+
var agentSink = new AgentFilesSink(options, ThisAssemblyVersion());
1523+
var agentResult = await agentSink.PublishAsync(agentProject, agentSections);
1524+
1525+
if (!agentResult.Success)
1526+
{
1527+
AnsiConsole.MarkupLine($"[red]✗[/] {Markup.Escape(agentResult.Summary)}");
1528+
return;
1529+
}
1530+
1531+
agentHashCalc.SaveHash(projectPath, agentProject.SourceHash);
1532+
1533+
// What the agent now knows, stated as facts rather than file sizes -- the point of the command
1534+
// is the knowledge, not the bytes.
1535+
var agentActions = agentProject.Controllers.Sum(c => c.Actions.Count);
1536+
var knows = new Table().Border(TableBorder.Rounded).AddColumn("Your agent now knows").AddColumn("");
1537+
knows.AddRow("Business entities", agentProject.Entities.Count.ToString());
1538+
knows.AddRow("Controllers", agentProject.Controllers.Count.ToString());
1539+
knows.AddRow("Actions", agentActions.ToString());
1540+
knows.AddRow("ORM", agentProject.OrmType);
1541+
if (agentProject.Navigation.Count > 0)
1542+
knows.AddRow("Navigation groups", agentProject.Navigation.Count.ToString());
1543+
if (agentProject.ModelEditorInfo is { } modelInfo && modelInfo.Views.Count > 0)
1544+
knows.AddRow("Model Editor views", modelInfo.Views.Count.ToString());
1545+
AnsiConsole.Write(knows);
1546+
1547+
AnsiConsole.WriteLine();
1548+
foreach (var artifact in agentResult.Artifacts.Where(a =>
1549+
!a.Contains(AgentContextGenerator.DetailFolder, StringComparison.Ordinal)))
1550+
{
1551+
AnsiConsole.MarkupLine($"[green]✓[/] {Markup.Escape(artifact)}");
1552+
}
1553+
1554+
var detailCount = agentResult.Artifacts.Count(a =>
1555+
a.Contains(AgentContextGenerator.DetailFolder, StringComparison.Ordinal));
1556+
if (detailCount > 0)
1557+
{
1558+
AnsiConsole.MarkupLine(
1559+
$"[grey]+ {detailCount} detail files in {AgentContextGenerator.DetailFolder}/ (read on demand, not loaded every request)[/]");
1560+
}
1561+
1562+
AnsiConsole.WriteLine();
1563+
AnsiConsole.MarkupLine("[grey]Ask your agent something only this codebase could answer.[/]");
1564+
}
1565+
1566+
// The tool version, stamped into generated files so a stale one can be identified.
1567+
static string ThisAssemblyVersion() =>
1568+
System.Reflection.Assembly.GetExecutingAssembly().GetName().Version is { } v
1569+
? $"{v.Major}.{v.Minor}.{v.Build}"
1570+
: "0.9.0";
1571+
13771572
static ExtractionOptions BuildExtractionOptions(string language, string? orm = null)
13781573
{
13791574
var options = new ExtractionOptions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
using XafLogicExplainer.Core.Interfaces;
2+
using XafLogicExplainer.Core.Models;
3+
using XafLogicExplainer.CopilotSync.Services;
4+
5+
namespace XafLogicExplainer.CopilotSync.Sinks;
6+
7+
/// <summary>
8+
/// Publishes extracted documentation to a PeopleWorks Copilot resource.
9+
/// </summary>
10+
/// <remarks>
11+
/// A thin adapter over <see cref="DocumentationUploader"/>, which holds the upload logic and is
12+
/// unchanged by this type. Its purpose is to make the remote target one
13+
/// <see cref="IDocumentationSink"/> among several rather than the destination the tool is built
14+
/// around, so a caller can choose where documentation goes without knowing what it is talking to.
15+
/// </remarks>
16+
public sealed class PeopleWorksCopilotSink : IDocumentationSink
17+
{
18+
private readonly DocumentationUploader _uploader;
19+
private readonly Action<string>? _onProgress;
20+
21+
/// <summary>Creates the sink.</summary>
22+
/// <param name="uploader">The configured uploader.</param>
23+
/// <param name="onProgress">Optional progress callback, surfaced to the console by the CLI.</param>
24+
public PeopleWorksCopilotSink(DocumentationUploader uploader, Action<string>? onProgress = null)
25+
{
26+
_uploader = uploader;
27+
_onProgress = onProgress;
28+
}
29+
30+
/// <inheritdoc />
31+
public string Name => "peopleworks-copilot";
32+
33+
/// <inheritdoc />
34+
/// <remarks>
35+
/// True, and deliberately visible: this sink sends documentation derived from the analyzed
36+
/// source code to a remote service, which is a decision a caller should be able to see before
37+
/// making it.
38+
/// </remarks>
39+
public bool IsRemote => true;
40+
41+
/// <inheritdoc />
42+
public async Task<SinkResult> PublishAsync(
43+
ExtractedProject project,
44+
IReadOnlyList<DocumentSection> sections,
45+
CancellationToken cancellationToken = default)
46+
{
47+
// The uploader renders its own sections from the project, because it also produces a
48+
// combined document and needs the two to agree. The sections passed in are therefore
49+
// unused here -- other sinks want them, this one does not.
50+
var result = await _uploader.UploadProjectDocumentationAsync(project, _onProgress);
51+
52+
if (!result.Success)
53+
{
54+
var detail = result.Errors.Count > 0
55+
? string.Join("; ", result.Errors)
56+
: result.Message;
57+
58+
return SinkResult.Fail($"Upload failed: {detail}");
59+
}
60+
61+
return SinkResult.Ok(
62+
$"{result.DocumentsUploaded} documents uploaded",
63+
result.UploadedDocuments.Select(d => d.FileName).ToList());
64+
}
65+
}

0 commit comments

Comments
 (0)