:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode
diff --git a/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ModuleCallersSelfLoopIT.java b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ModuleCallersSelfLoopIT.java
new file mode 100644
index 0000000..0f52efe
--- /dev/null
+++ b/ac-code-server/src/test/java/com/agenticcode/codeserver/api/ModuleCallersSelfLoopIT.java
@@ -0,0 +1,138 @@
+package com.agenticcode.codeserver.api;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static io.restassured.RestAssured.given;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.not;
+
+/**
+ * Characterization test for the module-granularity {@code /callers} endpoint (audit WGEAGB0S).
+ *
+ * Two properties are asserted, both of which failed before the fix in
+ * {@code CypherQueries.callers(scope)}:
+ *
+ * - No module self-loop. A subprogram's top-level main body {@code PERFORM}s its own
+ * subroutines; those {@code CALLS} edges originate at the {@code MODULE} node itself. The old
+ * query matched {@code caller = m} and reported the module as its own caller
+ * ({@code MAINX â MAINX}), a self-loop {@code /callees} never mirrors. {@code scope=internal}
+ * now carries {@code AND caller <> m} and must never list the module itself.
+ * - External-only default. {@code scope=null} now defaults to {@code external}: the answer
+ * to "who calls this module" is the incoming CALLNAT/inheritance edges only. Intra-module
+ * subroutine wiring belongs to {@code scope=internal}. The old default merged both, so the
+ * module's own subroutines showed up as its callers.
+ *
+ *
+ * Fixtures: {@code MAINX} performs {@code SUB-A} from its main body, and {@code SUB-A} performs
+ * {@code SUB-B} (a genuine function→function internal edge). {@code CALLERX} statically
+ * {@code CALLNAT 'MAINX'} — the one real external caller.
+ */
+@QuarkusTest
+class ModuleCallersSelfLoopIT {
+
+ private static final String PROJECT = "nat-callers-self-loop";
+
+ /**
+ * Main body performs SUB-A (caller = the module node); SUB-A performs SUB-B (caller = a FUNCTION).
+ */
+ private static final String MAINX = """
+ * Subprogram whose main body performs its own subroutine.
+ DEFINE DATA
+ LOCAL
+ 01 #X (A8)
+ END-DEFINE
+ *
+ PERFORM SUB-A
+ *
+ DEFINE SUBROUTINE SUB-A
+ PERFORM SUB-B
+ END-SUBROUTINE
+ *
+ DEFINE SUBROUTINE SUB-B
+ MOVE 'A' TO #X
+ END-SUBROUTINE
+ *
+ END
+ """;
+
+ /**
+ * The only genuine external caller: a static CALLNAT into MAINX.
+ */
+ private static final String CALLERX = """
+ * Statically calls MAINX.
+ DEFINE DATA
+ LOCAL
+ 01 #Y (A8)
+ END-DEFINE
+ *
+ CALLNAT 'MAINX'
+ *
+ END
+ """;
+
+ @TempDir
+ static Path root;
+
+ @BeforeAll
+ static void createProject() {
+ RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
+ write("MAINX.nat", MAINX);
+ write("CALLERX.nat", CALLERX);
+
+ given().contentType("application/json")
+ .body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
+ .when().post("/api/projects/" + PROJECT)
+ .then().statusCode(201);
+
+ given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true").then().statusCode(200);
+ }
+
+ private static void write(String fileName, String content) {
+ try {
+ Files.writeString(root.resolve(fileName), content);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ @Test
+ void defaultCallersAreExternalOnlyAndNeverIncludeTheModuleItself() {
+ // Default (external): the sole caller is CALLERX. Never the module itself, never a subroutine.
+ given().pathParam("name", "MAINX")
+ .when().get("/api/projects/" + PROJECT + "/modules/{name}/callers")
+ .then().statusCode(200)
+ .body("items.name", hasItem("CALLERX"))
+ .body("items.name", not(hasItem("MAINX")))
+ .body("items.name", not(hasItem("SUB-A")))
+ .body("items.name", not(hasItem("SUB-B")));
+ }
+
+ @Test
+ void externalScopeReturnsOnlyRealModuleCallers() {
+ given().pathParam("name", "MAINX").queryParam("scope", "external")
+ .when().get("/api/projects/" + PROJECT + "/modules/{name}/callers")
+ .then().statusCode(200)
+ .body("items.name", hasItem("CALLERX"))
+ .body("items.name", not(hasItem("MAINX")));
+ }
+
+ @Test
+ void internalScopeExcludesTheModuleSelfLoop() {
+ // Internal PERFORM wiring: SUB-A performs SUB-B (function->function) is present; the main
+ // body's PERFORM SUB-A (caller = the module node) must NOT surface the module as a caller.
+ given().pathParam("name", "MAINX").queryParam("scope", "internal")
+ .when().get("/api/projects/" + PROJECT + "/modules/{name}/callers")
+ .then().statusCode(200)
+ .body("items.name", hasItem("SUB-A"))
+ .body("items.name", not(hasItem("MAINX")));
+ }
+}
diff --git a/ac-mvn-plugins/pom.xml b/ac-mvn-plugins/pom.xml
index e4b861a..8e4e4cc 100644
--- a/ac-mvn-plugins/pom.xml
+++ b/ac-mvn-plugins/pom.xml
@@ -28,6 +28,12 @@
maven-plugin-api
${maven-plugin-api.version}
+
+ org.apache.maven
+ maven-core
+ ${maven-plugin-api.version}
+ provided
+
org.apache.maven.plugin-tools
maven-plugin-annotations
diff --git a/ac-mvn-plugins/src/main/java/com/agenticcode/version/BumpVersionMojo.java b/ac-mvn-plugins/src/main/java/com/agenticcode/version/BumpVersionMojo.java
new file mode 100644
index 0000000..d315c85
--- /dev/null
+++ b/ac-mvn-plugins/src/main/java/com/agenticcode/version/BumpVersionMojo.java
@@ -0,0 +1,131 @@
+package com.agenticcode.version;
+
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+/**
+ * Increments {@code agenticcode.version} (an integer build counter) in {@code application.properties}
+ * on every real package/install/deploy build, and stamps the same number into the ac-cli properties so
+ * {@code ac version} stays in lock-step with the server it was built alongside.
+ *
+ * Bound to {@link LifecyclePhase#GENERATE_RESOURCES} — before {@code process-resources} copies the
+ * file into {@code target/classes} — so the freshly built jar already reports the bumped number. That is
+ * why the bump lives in the build and not in the deploy script: a plain {@code mvn clean install} bumps
+ * too, and the jar's baked version matches the CLI stamp.
+ *
+ *
{@code generate-resources} also runs for {@code mvn test}/{@code compile}, which must NOT bump.
+ * The mojo therefore only acts when one of {@code triggerGoals} (default {@code package,install,deploy})
+ * is among the session's requested goals; {@code mvn test} skips.
+ */
+@Mojo(name = "bump-version", defaultPhase = LifecyclePhase.GENERATE_RESOURCES, threadSafe = true)
+public class BumpVersionMojo extends AbstractMojo {
+
+ @SuppressWarnings("NullAway.Init")
+ @Parameter(defaultValue = "${session}", readonly = true, required = true)
+ private MavenSession session;
+
+ /**
+ * Properties file holding the authoritative counter.
+ */
+ @SuppressWarnings("NullAway.Init")
+ @Parameter(defaultValue = "${project.basedir}/src/main/resources/application.properties", required = true)
+ private File versionFile;
+
+ @Parameter(defaultValue = "agenticcode.version")
+ private final String versionKey = "agenticcode.version";
+
+ /**
+ * ac-cli properties, stamped with the new counter so the CLI can detect a stale build.
+ */
+ @SuppressWarnings("NullAway.Init")
+ @Parameter(defaultValue = "${project.basedir}/../ac-cli/src/main/resources/agenticcode.properties")
+ private File cliVersionFile;
+
+ @Parameter(defaultValue = "version")
+ private final String cliVersionKey = "version";
+
+ /**
+ * Comma-separated goals that make this a real build worth bumping.
+ */
+ @Parameter(defaultValue = "package,install,deploy")
+ private final String triggerGoals = "package,install,deploy";
+
+ /**
+ * True if any requested goal is a trigger. Requested goals are the CLI phases/goals (e.g.
+ * {@code [clean, install]}); a plugin-goal build ({@code quarkus:dev}) or {@code [test]} yields false.
+ */
+ static boolean shouldBump(List requestedGoals, Set triggers) {
+ return requestedGoals.stream().anyMatch(triggers::contains);
+ }
+
+ /**
+ * Reads the integer value of {@code key} from a .properties {@code content}, or throws if absent/non-numeric.
+ */
+ static int currentValue(String content, String key) {
+ Matcher matcher = keyPattern(key).matcher(content);
+ if (!matcher.find()) {
+ throw new IllegalArgumentException("Property '" + key + "' not found");
+ }
+ return Integer.parseInt(matcher.group(1).trim());
+ }
+
+ /**
+ * Returns {@code content} with {@code key}'s value replaced by {@code value}, or throws if the key is absent.
+ */
+ static String withValue(String content, String key, int value) {
+ Matcher matcher = keyPattern(key).matcher(content);
+ if (!matcher.find()) {
+ throw new IllegalArgumentException("Property '" + key + "' not found");
+ }
+ return matcher.replaceFirst(Matcher.quoteReplacement(key + "=" + value));
+ }
+
+ private static Pattern keyPattern(String key) {
+ return Pattern.compile("^" + Pattern.quote(key) + "=(.*)$", Pattern.MULTILINE);
+ }
+
+ @Override
+ public void execute() throws MojoExecutionException {
+ Set triggers = Arrays.stream(triggerGoals.split(","))
+ .map(String::trim)
+ .filter(g -> !g.isEmpty())
+ .collect(Collectors.toSet());
+
+ List goals = session.getGoals();
+ if (!shouldBump(goals, triggers)) {
+ getLog().info("Not a package/install/deploy build (goals=" + goals + ") — version unchanged.");
+ return;
+ }
+
+ try {
+ String content = Files.readString(versionFile.toPath());
+ int next = currentValue(content, versionKey) + 1;
+ Files.writeString(versionFile.toPath(), withValue(content, versionKey, next));
+ getLog().info("Bumped " + versionKey + " -> " + next + " in " + versionFile);
+
+ if (cliVersionFile.exists()) {
+ String cliContent = Files.readString(cliVersionFile.toPath());
+ Files.writeString(cliVersionFile.toPath(), withValue(cliContent, cliVersionKey, next));
+ getLog().info("Stamped ac-cli " + cliVersionKey + " -> " + next + " in " + cliVersionFile);
+ } else {
+ getLog().warn("ac-cli properties not found, skipping stamp: " + cliVersionFile);
+ }
+ } catch (IOException exception) {
+ throw new MojoExecutionException("Failed to bump version in " + versionFile, exception);
+ }
+ }
+}
diff --git a/ac-mvn-plugins/src/test/java/com/agenticcode/version/BumpVersionMojoTest.java b/ac-mvn-plugins/src/test/java/com/agenticcode/version/BumpVersionMojoTest.java
new file mode 100644
index 0000000..5f71a31
--- /dev/null
+++ b/ac-mvn-plugins/src/test/java/com/agenticcode/version/BumpVersionMojoTest.java
@@ -0,0 +1,62 @@
+package com.agenticcode.version;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class BumpVersionMojoTest {
+
+ private static final Set TRIGGERS = Set.of("package", "install", "deploy");
+
+ @Test
+ void bumpsOnPackageInstallDeploy() {
+ assertTrue(BumpVersionMojo.shouldBump(List.of("clean", "install"), TRIGGERS));
+ assertTrue(BumpVersionMojo.shouldBump(List.of("package"), TRIGGERS));
+ assertTrue(BumpVersionMojo.shouldBump(List.of("deploy"), TRIGGERS));
+ }
+
+ @Test
+ void doesNotBumpOnTestOrCompileOrPluginGoal() {
+ assertFalse(BumpVersionMojo.shouldBump(List.of("test"), TRIGGERS));
+ assertFalse(BumpVersionMojo.shouldBump(List.of("clean", "compile"), TRIGGERS));
+ assertFalse(BumpVersionMojo.shouldBump(List.of("quarkus:dev"), TRIGGERS));
+ assertFalse(BumpVersionMojo.shouldBump(List.of(), TRIGGERS));
+ }
+
+ @Test
+ void currentValueReadsTheCounter() {
+ String content = "quarkus.http.port=8787\nagenticcode.version=77\nother=x\n";
+ assertEquals(77, BumpVersionMojo.currentValue(content, "agenticcode.version"));
+ }
+
+ @Test
+ void withValueReplacesOnlyThatKeyAndPreservesTheRest() {
+ String content = "quarkus.http.port=8787\nagenticcode.version=77\nother=77\n";
+ String bumped = BumpVersionMojo.withValue(content, "agenticcode.version", 78);
+ assertEquals("quarkus.http.port=8787\nagenticcode.version=78\nother=77\n", bumped);
+ }
+
+ @Test
+ void stampsCliStyleKey() {
+ String content = "version=77\n";
+ assertEquals("version=78\n", BumpVersionMojo.withValue(content, "version", 78));
+ }
+
+ @Test
+ void missingKeyThrows() {
+ assertThrows(IllegalArgumentException.class,
+ () -> BumpVersionMojo.currentValue("a=1\n", "agenticcode.version"));
+ assertThrows(IllegalArgumentException.class,
+ () -> BumpVersionMojo.withValue("a=1\n", "agenticcode.version", 2));
+ }
+
+ @Test
+ void roundTripIncrements() {
+ String content = "agenticcode.version=100\n";
+ int next = BumpVersionMojo.currentValue(content, "agenticcode.version") + 1;
+ assertEquals("agenticcode.version=101\n", BumpVersionMojo.withValue(content, "agenticcode.version", next));
+ }
+}
diff --git a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java
index b605873..0d8f662 100644
--- a/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java
+++ b/ac-neo4j-store/src/main/java/com/agenticcode/neo4jstore/graph/CypherQueries.java
@@ -1662,13 +1662,17 @@ public final class CypherQueries {
* {@code m}), tagged with {@code edgeKind = EXTENDS}/{@code IMPLEMENTS} â mirroring
* {@link #callees(String)}. {@code scope} may be {@code "external"} (callers into the module
* itself â CALLNAT and inheritance), {@code "internal"} (PERFORM into own subroutines only),
- * or {@code null} for all callers.
+ * or {@code null} which defaults to {@code "external"} (the module's genuine callers; internal
+ * subroutine wiring is reachable via {@code "internal"} or the function-level callers endpoint).
*/
public static String callers(@Nullable String scope) {
+ // Item: module-granularity callers. "external"/null = genuine callers of the module itself
+ // (incoming CALLNAT/inheritance). "internal" = PERFORM into the module's own subroutines;
+ // `caller <> m` drops the main body's self-loop (MODULE performing its own subroutines),
+ // which would otherwise surface the module as its own caller.
String scopeFilter = switch (scope == null ? "" : scope.toLowerCase(Locale.ROOT)) {
- case "external" -> "AND target = m";
- case "internal" -> "AND target <> m";
- default -> "";
+ case "internal" -> "AND target <> m AND caller <> m";
+ default -> "AND target = m"; // "external" and the default
};
return """
MATCH (m:AstNode {type: 'MODULE', name: $name, project: $project})
diff --git a/manage-ac.sh b/manage-ac.sh
index 2d9db57..ff4a7e1 100755
--- a/manage-ac.sh
+++ b/manage-ac.sh
@@ -35,21 +35,12 @@ require_docker() {
fi
}
-bump_version() {
- local props="ac-code-server/src/main/resources/application.properties"
- local current next
- current="$(grep -m1 '^agenticcode.version=' "$props" | cut -d= -f2)"
- if [[ -z "$current" ]]; then
- echo "Could not find agenticcode.version in $props" >&2
- exit 1
- fi
- next=$((current + 1))
- sed -i "s/^agenticcode.version=.*/agenticcode.version=$next/" "$props"
- log "Bumped agenticcode.version: $current -> $next"
-}
-
# Stamps ac-cli's bundled version from ac-code-server's agenticcode.version (the single
# source of truth), so 'ac version' can detect a stale CLI against a newer server.
+# Note: a full build already bumps agenticcode.version and stamps ac-cli via the
+# ac-mvn-plugins 'bump-version' mojo (bound to ac-code-server's generate-resources phase).
+# This function is only used by the CLI-only rebuild path, which must NOT bump but must
+# still sync the CLI to the current server version.
stamp_cli_version() {
local props="ac-code-server/src/main/resources/application.properties"
local cli_props="ac-cli/src/main/resources/agenticcode.properties"
@@ -64,8 +55,8 @@ stamp_cli_version() {
}
build_all() {
- bump_version
- stamp_cli_version
+ # The version bump + ac-cli stamp happen inside the Maven build (ac-mvn-plugins
+ # 'bump-version' mojo on ac-code-server), since 'install' is a trigger goal.
log "Building all modules (mvn clean install -DskipTests)"
mvn clean install -DskipTests
}
@@ -228,8 +219,10 @@ Usage: ./manage-ac.sh
Notes:
- The server answers on http://localhost:8787, the UI on http://localhost:5174.
- - 'deploy' bumps agenticcode.version on every run, which is why no argument
- means help rather than deploy.
+ - The version bump lives in the Maven build (ac-mvn-plugins 'bump-version' mojo):
+ every 'mvn package'/'install'/'deploy' bumps agenticcode.version and stamps ac-cli.
+ 'mvn test'/'compile'/'quarkus:dev' do not. 'deploy' bumps because it runs a full
+ install — which is why a bare, argument-less invocation means help, not deploy.
EOF
}
diff --git a/prompts/wgeagb0s-deep-api-audit.md b/prompts/wgeagb0s-deep-api-audit.md
new file mode 100644
index 0000000..016d491
--- /dev/null
+++ b/prompts/wgeagb0s-deep-api-audit.md
@@ -0,0 +1,59 @@
+# Deep API Audit — WGEAGB0S + all subprograms
+
+**Preconditions:** The agentic server is up (`http://localhost:8787`) and a deep refresh of
+project `upms` has completed. If the server is unreachable, stop and ask the human to run
+`./manage-ac.sh deploy` — never start Docker yourself.
+
+## Task
+
+Perform a **very deep analysis of all agentic API endpoints**, in two tiers:
+
+1. **Deep dive — WGEAGB0S** (worked example): ingest module **WGEAGB0S** and, for each endpoint,
+ determine whether the response is **correct** by **manually reading the Natural source** and
+ comparing it against the API output, field-by-field.
+2. **Broad sweep — every subprogram in `upms`**: verify the same endpoints across **all**
+ subprograms, not just WGEAGB0S. Manual reading of every source is infeasible at that scale, so
+ drive the sweep with **automated cross-checks** (below) that catch whole classes of defects, and
+ escalate any module that fails a check to a manual, source-level deep dive like tier 1.
+
+## How to work
+
+1. **Use the REST API** (`GET /api/projects/upms/modules/{name}/...`), falling back to the `ac` CLI
+ for quick manual checks. Do not use MCP for this audit.
+ For WGEAGB0S (and any escalated module) pull every relevant endpoint: `callees`, `callers`,
+ `db-accesses`, `functions`, `data-structures`, `dispatch-table`, `digest`, `context`,
+ `call-tree`, `sql-statements`, `graph`.
+2. **Read the Natural source manually** (the module plus its `USING` data areas / copycodes) and
+ verify each response field-by-field: call graph, DB accesses (READ/WRITE mode), variable
+ reads/writes, field/placeholder resolution, dispatch table. Note the sources are ISO-8859
+ encoded — use `grep -a` / an encoding-aware reader.
+3. **Broad-sweep cross-checks (all subprograms).** Enumerate every subprogram (`GET /modules?...`,
+ or the `generated_src`/`user_exit`/`manual` `subprogram/` dirs) and assert API-derivable
+ invariants that need no per-module reading, e.g.:
+ - **No `MODULE` self-loop:** `callers`/`callees` never list a module as its own caller/callee.
+ - **`callers` default is external-only:** the default view lists incoming CALLNAT/inheritance
+ only — never the module's own subroutines (those belong to `scope=internal`).
+ - **Callee resolution matches source:** every `callees` `CALLNAT` target appears as a real
+ `CALLNAT ''` in the module or one of its `INCLUDE`d copycodes (grep-verifiable), with
+ correct `viaCopycode`/`includedAt` provenance — and no phantom targets.
+ - **Function count parity:** `digest.functionCount` == the number of `DEFINE SUBROUTINE` in the
+ source; every performed subroutine exists as a `FUNCTION`.
+ - **DB access sanity:** modules with no `READ/FIND/STORE/UPDATE/DELETE` report empty
+ `db-accesses`/`sql-statements`; those with them resolve to real `DB_TABLE`s.
+ Rank modules by check failures; the worst offenders get a tier-1 manual deep dive.
+4. **Report every discrepancy** you find. For each: the endpoint and exact wrong value, the ground
+ truth from the source (with line references), and the suspected root cause in the
+ parser/enricher/Cypher. For sweep findings, report the failing invariant, the count and list of
+ affected modules, and a representative worked example.
+5. **Propose a concrete fix** for each error.
+6. **Write failing characterization tests first** (Testcontainers ITs with minimal Natural fixtures
+ that reproduce the bug), then confirm they go green after the fix.
+
+## Output
+
+A structured report:
+
+- **Per-endpoint** for WGEAGB0S: PASS or the list of discrepancies with ground-truth evidence,
+ root-cause hypothesis, fix proposal, and the test that covers it.
+- **Broad sweep:** per invariant, PASS or the affected-module count + list + a worked example, with
+ the same root-cause/fix/test treatment for each distinct defect class.
diff --git a/x-docs/features.md b/x-docs/features.md
index 3bf917a..62ffdb7 100644
--- a/x-docs/features.md
+++ b/x-docs/features.md
@@ -2173,3 +2173,20 @@ provably equivalent when bounded to `*0..1`, which cannot walk the cycles. 20 re
`*_FOR_MODULES` batch variants). Query-only change (no `recreate`). Guard `ReadPathBoundedTraversalIT`
(EXPLAIN asserts no unbounded CONTAINS expand); full IT suite 193/0/0. Live (v76): `ACCNPE01 callees`
> 2 min → 0.16 s, `digest` >10 s → 3.3 s, `context` >10 s → 3.1 s.
+
+## Version bump moved from deploy into the build — 2026-07-19
+
+`agenticcode.version` (the integer build counter reported by `/api/version` and used by `ac version` for
+stale-CLI detection) used to be incremented by `manage-ac.sh deploy` (shell `bump_version`), so a plain
+`mvn clean install` never bumped and only a deploy did. It now lives in the Maven build: a new
+`ac-mvn-plugins` mojo `bump-version`, bound to `ac-code-server`'s `generate-resources` phase, increments
+the counter and stamps the same number into `ac-cli`'s `agenticcode.properties`. Binding to
+`generate-resources` (before `process-resources`) means the freshly built jar already reports the bumped
+number, keeping the jar's baked version and the CLI stamp in lock-step. The mojo only acts when a
+requested goal is `package`/`install`/`deploy` (read from `MavenSession.getGoals()`), so `mvn test`,
+`mvn compile` and `quarkus:dev` do not bump; `deploy` bumps because it runs a full `install`. Every such
+build bumps unconditionally (no source-change gating — high numbers are harmless). `manage-ac.sh`'s
+`bump_version` was removed; `stamp_cli_version` is kept only for the CLI-only rebuild path (`cli`), which
+syncs the CLI to the current server version without bumping. Logic unit-tested (`BumpVersionMojoTest`,
+7/0/0); live-verified: `mvn generate-resources` left the counter unchanged, `mvn package` bumped 77→78 and
+stamped ac-cli 78, and `target/classes/application.properties` carried 78 (timing correct).
diff --git a/x-docs/mcp-api-usage-ac-implementation.md b/x-docs/mcp-api-usage-ac-implementation.md
index 1d1b986..66078af 100644
--- a/x-docs/mcp-api-usage-ac-implementation.md
+++ b/x-docs/mcp-api-usage-ac-implementation.md
@@ -446,7 +446,7 @@ origins (`http://localhost:5173`, `http://localhost:4173`) — extend the
| `GET /loc?language=&sourceFile=` | Per-language LoC/SLoC rollup (fileCount/loc/sloc) + project total; each file counted once (item 46). For a generated/user_exit project also `userExitLoc`/`userExitSloc` + `generatedExclusiveLoc`/`generatedExclusiveSloc` (item 47) |
| `GET /modules/{name}/digest` | Tiny triage view before deciding which modules to expand |
| `GET /modules/{name}/context` | One-shot overview: functions, callers, callees, DB accesses, SQL/variable summaries (`?include=` for full lists) |
-| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES` |
+| `GET /modules/{name}/callers` \| `/callees` | Direct callers/callees incl. `EXTENDS`/`IMPLEMENTS`/`INJECTS`/`REFERENCES`. `callers` `scope`: **`external` (default)** = modules that call this one (CALLNAT/inheritance); `internal` = the module's own subroutines' `PERFORM` wiring. The default is external-only and never lists the module as its own caller (no `MODULE→MODULE` self-loop); use `scope=internal` or `/functions/{fn}/callers` for intra-module wiring. `callees` is unchanged (default lists both external CALLNAT and internal PERFORM targets) |
| `GET /modules/{name}/functions/{function}/callers` | **FUNCTION-level callers** (item 52): who `PERFORM`s (Natural) or calls (Java cross-class) a specific subroutine/method, with call-site `lineNos`. Finer-grained than the module-level `/callers` (which is module→module). Same `CallRefResponse` shape. MCP `function_callers`, CLI `ac function-callers ` |
| `GET /modules/{name}/call-tree?depth=` | Transitive call graph to scope a feature |
| `GET /modules/{name}/graph?direction=&depth=&limit=` | Ego graph (item 49): bounded module-level call neighbourhood as **nodes + edges** (unlike call-tree). `direction` = `out`/`in`/`both`; `limit` caps nodes (BFS order) and sets `truncated`; unresolved targets carry `unresolved=true` + empty `sourceFile`. MCP `ego_graph`, CLI `ac ego-graph` |
diff --git a/x-docs/roadmap.md b/x-docs/roadmap.md
index 8c68f25..a56168e 100644
--- a/x-docs/roadmap.md
+++ b/x-docs/roadmap.md
@@ -184,11 +184,20 @@ wrong answer, found by the 2026-07-17 `VMULTMN4` audit.)*
external subroutines are a language feature this parser does not resolve — worth its own item if the
corpus ever needs it.)*
-- *Not a bug (verified):* the Overview "Called by" lists the module's own internal subroutines (from
- `context.callers`, which includes internal PERFORM callers) — noisy but accurate. Ego-graph
- `direction=in` returning 0 for `WGEAGB0S` is **correct** (nothing `CALLNAT`s it; it's a top-level XML
- entry). Payload `direction` is always `REQUEST` for PDA-derived contracts (a single interface PDA
- doesn't encode direction) — a documented limitation, not a bug.
+- [x] **Module `callers` default is external-only; no `MODULE` self-loop** (2026-07-19, WGEAGB0S deep
+ API audit). Two coupled defects in `CypherQueries.callers(scope)`: (1) the top-level main body's
+ `PERFORM`s originate at the `MODULE` node, so `scope=internal`/default reported the module as its own
+ caller (`WGEAGB0S → WGEAGB0S`), a self-loop `callees` never mirrors — fixed with `AND caller <> m` on
+ the internal scope; (2) the default (`scope=null`) merged external callers with intra-module PERFORM
+ wiring, so a module's own subroutines showed up as its "callers" — the default now maps to `external`
+ (genuine incoming CALLNAT/inheritance only). `context`/`digest` (both call `callers(…, null)`)
+ inherit the clean view; `scope=internal` still exposes function→function PERFORM wiring; callees
+ unchanged. Covered by `ModuleCallersSelfLoopIT` (fails 2/3 before the fix). MCP `callers` tool
+ description + REST endpoint doc + `mcp-api-usage-ac-implementation.md` updated.
+ *(Supersedes the earlier "Not a bug (verified): `context.callers` includes internal PERFORM callers —
+ noisy but accurate" note.)* Ego-graph `direction=in` for `WGEAGB0S` now returns its dynamic callers
+ `W-LST-N0`/`W-MNT-N0` (item 75). Payload `direction` is always `REQUEST` for PDA-derived contracts (a
+ single interface PDA doesn't encode direction) — a documented limitation, not a bug.
## Natural parser robustness