Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fe9297b
release 0.1.10: every package and plugin manifest at 0.1.10 (the pars…
swapnilpaliwal-sd Oct 10, 2026
f3f10fb
plugin: show the user, after each turn, where an AxiomCode answer ver…
swapnilpaliwal-sd Oct 12, 2026
72b2678
plugin: the receipt counts the answers that led the work and names th…
swapnilpaliwal-sd Oct 12, 2026
43bcdec
plugin: a test file an answer names twice is one test file in the rec…
swapnilpaliwal-sd Oct 12, 2026
148e9c5
plugin: the receipt credits core files the graph named, from its note…
swapnilpaliwal-sd Oct 12, 2026
49a96f0
plugin: the assists report tells what the agent did with each answer,…
swapnilpaliwal-sd Oct 12, 2026
e6f9d68
plugin: the assists page rebuilds itself whenever a turn adds to the …
swapnilpaliwal-sd Oct 12, 2026
af4da87
plugin: the agent ends its reply with the assist receipt, and calls s…
swapnilpaliwal-sd Oct 12, 2026
1c6254e
plugin: a picked test passes only when the runner says so, on its lat…
swapnilpaliwal-sd Oct 12, 2026
b39c043
plugin: a test run is credited to the latest answer that picked its t…
swapnilpaliwal-sd Oct 12, 2026
d734ff7
plugin: the terminal shows the assist receipt once (#1935)
swapnilpaliwal-sd Oct 12, 2026
60c38d0
Revert "plugin: the terminal shows the assist receipt once (#1935)"
swapnilpaliwal-sd Oct 12, 2026
6e8cbd4
plugin: the receipt says an answer came from an earlier turn, not tha…
swapnilpaliwal-sd Oct 12, 2026
67ec442
plugin: a slow graph rebuilds after 7 edits instead of 5
swapnilpaliwal-sd Oct 12, 2026
d0683fc
plugin: axiomcode graph draws the codebase as a page again
swapnilpaliwal-sd Oct 12, 2026
a7a66bf
plugin: the places view opens with what the question resolved to (#1939)
swapnilpaliwal-sd Oct 12, 2026
f9e492f
Merge pull request #1940 from AxiomCodeAI/fix/path-start-summary
swapnilpaliwal-sd Oct 12, 2026
5f281c9
Merge pull request #1937 from AxiomCodeAI/feat/refresh-batch-7
swapnilpaliwal-sd Oct 13, 2026
a878050
Merge pull request #1938 from AxiomCodeAI/feat/graph-verb
swapnilpaliwal-sd Oct 13, 2026
28847e0
Merge apps/integration-0.1.10 into feat/assist-receipts
swapnilpaliwal-sd Oct 13, 2026
81a60df
typescript: a dependency staged with --library keeps the declarations…
swapnilpaliwal-sd Oct 13, 2026
4cdf69c
Merge pull request #1936 from AxiomCodeAI/feat/assist-receipts
swapnilpaliwal-sd Oct 13, 2026
7456ada
Merge pull request #1941 from AxiomCodeAI/fix/ts-library-build-dirs
swapnilpaliwal-sd Oct 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gemini-extension.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.9",
"version": "0.1.10",
"description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.",
"contextFileName": "plugins/axiomcode/AGENTS.md",
"mcpServers": {
Expand Down
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@axiomcode/code-graph",
"version": "0.1.9",
"version": "0.1.10",
"description": "AxiomCode Graph: a resolved call graph of your codebase grounded in formal methods, so you and your coding agents can see who calls what, what a change breaks, and which tests it reaches.",
"repository": {
"type": "git",
Expand Down Expand Up @@ -31,11 +31,11 @@
],
"license": "FSL-1.1-Apache-2.0",
"optionalDependencies": {
"@axiomcode/engine-darwin-arm64": "0.1.9",
"@axiomcode/engine-darwin-x64": "0.1.9",
"@axiomcode/engine-linux-x64": "0.1.9",
"@axiomcode/engine-linux-arm64": "0.1.9",
"@axiomcode/engine-win32-x64": "0.1.9"
"@axiomcode/engine-darwin-arm64": "0.1.10",
"@axiomcode/engine-darwin-x64": "0.1.10",
"@axiomcode/engine-linux-x64": "0.1.10",
"@axiomcode/engine-linux-arm64": "0.1.10",
"@axiomcode/engine-win32-x64": "0.1.10"
},
"bin": {
"axiomcode": "bin/axiomcode.js"
Expand Down
8 changes: 8 additions & 0 deletions parser/src/constants/typescript-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export const TS_SKIP_DIRECTORIES = [
'.next', '.nuxt', '.turbo', '.cache', '.yarn', 'bower_components',
] as const;

/**
* The entries of TS_SKIP_DIRECTORIES that hold a build's output. For the project under
* analysis they are a copy of its own source; for a DEPENDENCY they are where its
* declaration files live: a root `.d.ts` that re-exports `./dist/...`, or a package whose
* every declaration sits under `dist/types/`. A library walk keeps them.
*/
export const TS_BUILD_OUTPUT_DIRECTORIES = ['dist', 'build', 'out'] as const;

/** CSV file names for the TypeScript fact tables. */
export const TYPESCRIPT_CSV_FILES = {
MODULES: 'all-typescript-modules.csv',
Expand Down
32 changes: 32 additions & 0 deletions parser/src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,16 @@ export async function extractProject(opts: ExtractOptions): Promise<void> {
const javaProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVA);
const pythonProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.PYTHON);
const typescriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.TYPESCRIPT);
// A DEPENDENCY whose declarations all sit in a build directory: detection looks for
// TypeScript near the top and skips build output, so such a package was staged as
// JavaScript only and every TypeScript call into it stayed unresolved. Its package.json
// says it has types; that makes the root a TypeScript project for a library walk.
if (opts.library === true && typescriptProjects.length === 0 && packageDeclaresTypes(absolutePath)) {
typescriptProjects.push({
name: path.basename(absolutePath), path: absolutePath,
language: ProjectLanguage.TYPESCRIPT, hasSourceFiles: true,
});
}
const javascriptProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.JAVASCRIPT);
const csharpProjects = scanner.filterByLanguage(allProjects, ProjectLanguage.CSHARP);

Expand Down Expand Up @@ -374,6 +384,7 @@ export async function extractProject(opts: ExtractOptions): Promise<void> {
? ['node_modules', '.git', 'dist', 'build', 'out', 'coverage',
'test', 'tests', '__tests__', '.next', '.turbo']
: undefined,
libraryRoot: opts.library === true,
})))),
timed(Promise.all(pythonProjects.map((project, i) =>
pythonAnalyzer.analyze({
Expand Down Expand Up @@ -497,3 +508,24 @@ export async function extractProject(opts: ExtractOptions): Promise<void> {
console.log(`\n⏱️ TOTAL analysis time: ${elapsed}s`);
console.log('✨ Analysis complete!\n');
}

/**
* Does the package.json at `root` declare type definitions: a `types` / `typings` field,
* or a `types` condition anywhere in its `exports` map?
*/
function packageDeclaresTypes(root: string): boolean {
let manifest: unknown;
try {
manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
} catch {
return false;
}
if (typeof manifest !== 'object' || manifest === null) return false;
const m = manifest as Record<string, unknown>;
if (typeof m.types === 'string' || typeof m.typings === 'string') return true;
const hasTypesCondition = (node: unknown): boolean =>
typeof node === 'object' && node !== null
&& Object.entries(node as Record<string, unknown>).some(([key, value]) =>
key === 'types' ? typeof value === 'string' : hasTypesCondition(value));
return hasTypesCondition(m.exports);
}
70 changes: 70 additions & 0 deletions parser/src/test/discovery-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,76 @@ const CHECKS: Check[] = [
return null;
},
},
{
name: 'a-library-keeps-the-declarations-under-its-build-directory',
proves: 'staged with --library, a package whose root .d.ts re-exports `./dist/...` has the '
+ 'declarations under dist/ in its TypeScript modules; the same tree analysed as the project '
+ 'still skips dist/ (control)',
rulesOut: 'walking a dependency with the project skip list: dist/ was skipped, the root .d.ts '
+ 'pointed at nothing, and every call into the package stayed ambiguous_unknown',
run: async (tmp) => {
const files = {
'package.json': '{"name":"reexporter","version":"1.0.0","types":"index.d.ts"}',
'index.d.ts': "export * from './dist/api';\n",
'dist/api.d.ts': 'export declare function connect(url: string): void;\n',
};
const modulesOf = async (name: string, library: boolean): Promise<string> => {
const root = build(tmp, name, files);
const out = path.join(tmp, `${name}-out`);
const silence = console.log;
console.log = () => {};
try {
await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language', library });
} finally {
console.log = silence;
}
const csv = path.join(out, 'typescript', 'all-typescript-modules.csv');
return fs.existsSync(csv) ? fs.readFileSync(csv, 'utf-8') : '';
};
const asLibrary = await modulesOf('reexport-lib', true);
if (!asLibrary.includes('index.d.ts')) return 'control: the root index.d.ts is not analysed';
if (!asLibrary.includes('dist/api.d.ts')) return 'as a library, dist/api.d.ts is not analysed';
const asProject = await modulesOf('reexport-project', false);
if (asProject.includes('dist/api.d.ts')) return 'as the project, dist/ is analysed: a committed build copy would double the source';
return null;
},
},
{
name: 'a-library-whose-declarations-all-sit-in-dist-is-typescript',
proves: 'staged with --library, a package with no declaration outside dist/ whose exports map '
+ 'names a `types` condition is analysed as TypeScript; a package that declares no types '
+ 'gets no typescript/ folder (control)',
rulesOut: 'detecting TypeScript only near the top of the tree: such a package was staged as '
+ 'JavaScript alone, so TypeScript calls into it had no declaration to resolve against',
run: async (tmp) => {
const run = async (name: string, files: Record<string, string>): Promise<string> => {
const root = build(tmp, name, files);
const out = path.join(tmp, `${name}-out`);
const silence = console.log;
console.log = () => {};
try {
await extractProject({ projectPath: root, versionLink: 'v1', outputDir: out, layout: 'per-language', library: true });
} finally {
console.log = silence;
}
const csv = path.join(out, 'typescript', 'all-typescript-modules.csv');
return fs.existsSync(csv) ? fs.readFileSync(csv, 'utf-8') : '';
};
const typed = await run('dist-types-only', {
'package.json': JSON.stringify({ name: 'typedpkg', version: '1.0.0',
exports: { './server': { types: './dist/types/server.d.ts', import: './dist/esm/server.js' } } }),
'dist/types/server.d.ts': 'export declare function session(): string;\n',
'dist/esm/server.js': 'export function session() { return ""; }\n',
});
if (!typed.includes('dist/types/server.d.ts')) return 'a package declaring types only under dist/ has no TypeScript module';
const untyped = await run('dist-js-only', {
'package.json': '{"name":"plainpkg","version":"1.0.0","main":"dist/index.js"}',
'dist/index.js': 'exports.go = function go() {};\n',
});
if (untyped !== '') return 'control: a package that declares no types got TypeScript modules';
return null;
},
},
{
name: 'per-language-keeps-the-javascript-of-a-dist-shipping-package',
proves: 'extractProject in per-language layout writes javascript/ for a package whose only '
Expand Down
11 changes: 11 additions & 0 deletions parser/src/workflows/typescript/typescript-project-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as ts from 'typescript';
import { ENTITY_IDENTIFIERS } from '@/constants/entity-constants';
import {
TS_SKIP_DIRECTORIES,
TS_BUILD_OUTPUT_DIRECTORIES,
TS_SOURCE_EXTENSIONS,
TYPESCRIPT_CSV_FILES,
} from '@/constants/typescript-constants';
Expand Down Expand Up @@ -98,6 +99,13 @@ export interface TypeScriptAnalysisOptions {
/** A pre-computed hash, for a caller that already has one. */
readonly serviceVersionLinkHash?: string;
readonly excludeDirs?: readonly string[];
/**
* The root is a DEPENDENCY staged with `--library`, not the project under analysis.
* Its build-output directories (TS_BUILD_OUTPUT_DIRECTORIES) are then walked: that is
* where a published package keeps its declarations, and skipping them left every call
* into such a package unresolved. Stated by the caller, as JavaScript's `libraryRoot` is.
*/
readonly libraryRoot?: boolean;
}

export interface TypeScriptAnalysisSummary {
Expand Down Expand Up @@ -246,6 +254,9 @@ export class TypeScriptProjectAnalyzer {

const rootDir = path.resolve(options.rootDir);
const excludes = new Set<string>(options.excludeDirs ?? TS_SKIP_DIRECTORIES);
if (options.libraryRoot === true) {
for (const name of TS_BUILD_OUTPUT_DIRECTORIES) excludes.delete(name);
}
const configResolver = new TsConfigResolver();
// A PROGRAM is the unit of merge scope, so when the root declares one its
// file list wins over a directory walk. This is not an optimisation: a
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "axiomcode",
"description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.",
"version": "0.1.9",
"version": "0.1.10",
"author": {
"name": "AxiomCode"
}
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.9",
"version": "0.1.10",
"description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed.",
"author": { "name": "AxiomCode", "url": "https://github.com/AxiomCodeAI/axiomcodegraph" },
"homepage": "https://github.com/AxiomCodeAI/axiomcodegraph",
Expand Down
2 changes: 1 addition & 1 deletion plugins/axiomcode/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axiomcode",
"version": "0.1.9",
"version": "0.1.10",
"description": "Ask your repository how its code connects: who calls this, what breaks if I change it, which tests an edit reaches, how A reaches B. Answers come from a resolved call graph and are verified against it; nothing is guessed. Java, TypeScript, Python, JavaScript.",
"author": { "name": "AxiomCode" },
"homepage": "https://github.com/AxiomCodeAI/axiomcodegraph",
Expand Down
99 changes: 99 additions & 0 deletions plugins/axiomcode/hooks/assist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Stop: when the turn's work stood on an AxiomCode answer, say so under the reply (#1935).

Agents finish with and without the graph, and they rarely credit the answer they acted on, so the user never sees what
it did. At the end of each turn this reads the session's transcript and prints, for each chain new in that turn, one
line the user can check (skills/axiomcode/scripts/ax_assists.py holds the rules):

◆ AxiomCode this turn · 1 answer led the agent's work
• Helped: `impact total` showed pricing.py (new to the agent) → it edited it → both picked test files (…) pass

Rules it holds itself to:
· NOTHING FROM THE AGENT'S TEXT. The reply has already been shown when this runs; every link is read from the tool
calls and their results, and a link that was not checked is not printed.
· SILENT unless an answer led somewhere: no query, or a query nobody acted on, prints nothing. Only what helped is
counted; the answers that led nowhere are never shown.
· THE AGENT QUOTES IT. In Claude Code the receipt goes back to the agent once (decision "block"), which ends its
reply with it word for word, so it is part of the answer in every client (`-p`, the SDK, an IDE). The second stop
(stop_hook_active) checks the reply carries every line; when it does not, the receipt is shown as a message
instead. Cursor and Gemini cannot hand it back, so they get the message.
AXIOMCODE_ASSISTS=off turns it off.
"""
import json, os, subprocess, sys, time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'skills', 'axiomcode', 'scripts'))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _host

if (os.environ.get('AXIOMCODE_ASSISTS') or '').lower() in ('off', '0', 'false', 'no'):
sys.exit(0)
try:
ev = _host.read()
except Exception:
sys.exit(0)
if _host.CURSOR: # Cursor's stop hook takes no message for the user
sys.exit(0)
tr = ev.get('transcript_path') or ''
if not tr or not os.path.isfile(tr):
sys.exit(0)
cwd = ev.get('cwd') or os.getcwd()
try:
r = subprocess.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'], capture_output=True, text=True, timeout=5)
root = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else cwd
except Exception:
root = cwd
ASK = ('AxiomCode checked the tool calls of this turn and recorded where its answers led your work. End your reply with '
'the block below, copied exactly as written (inside a ``` block), and nothing else: run no tools, add no claims '
'of your own about it.\n\n')


def _pending(root):
return os.path.join(root, '.axiomcode', 'assists.pending.json')


def _quoted(transcript, msg):
"""whether the agent's text after the hand-back carries every line of the receipt"""
want = [l.strip() for l in msg.splitlines() if l.strip()]
for _ in range(6): # the host may still be writing the reply's last message
said, after = [], False
for line in open(transcript, encoding='utf-8', errors='replace'):
try:
e = json.loads(line)
except ValueError:
continue
c = (e.get('message') or {}).get('content') if isinstance(e, dict) else None
if e.get('type') == 'user' and ASK[:40] in json.dumps(c, ensure_ascii=False):
said, after = [], True
elif after and e.get('type') == 'assistant' and isinstance(c, list):
said += [x.get('text') or '' for x in c if isinstance(x, dict) and x.get('type') == 'text']
text = '\n'.join(said)
if all(w in text for w in want):
return True
time.sleep(0.5)
return False


if ev.get('stop_hook_active'):
# the agent was handed the receipt; show it as a message only when its reply did not carry it
try:
pend = json.load(open(_pending(root)))
os.remove(_pending(root))
except Exception:
sys.exit(0)
if pend.get('session') == str(ev.get('session_id') or '') and not _quoted(tr, pend['msg']):
print(json.dumps({'systemMessage': pend['msg']}))
sys.exit(0)
try:
import ax_assists
msg = ax_assists.at_stop(tr, os.path.realpath(root), str(ev.get('session_id') or ''))
except Exception:
sys.exit(0) # a receipt is never worth a failed turn
if not msg:
sys.exit(0)
if _host.GEMINI:
print(json.dumps({'systemMessage': msg}))
sys.exit(0)
try:
json.dump({'session': str(ev.get('session_id') or ''), 'msg': msg}, open(_pending(root), 'w'))
print(json.dumps({'decision': 'block', 'reason': ASK + msg}))
except Exception:
print(json.dumps({'systemMessage': msg}))
9 changes: 9 additions & 0 deletions plugins/axiomcode/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run.js\" assist.py",
"timeout": 15
}
]
}
],
"SessionStart": [
Expand Down
16 changes: 16 additions & 0 deletions plugins/axiomcode/skills/axiomcode/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ With them, a call into a dependency resolves to its declaration and a chain is t
types (`client.post(...).json()`). Library bodies are not walked, so a dependency calling back into the project is
not found this way.

## report

At the end of a turn where an answer verifiably led your work (a file the answer showed first that you then read or
edited, or the tests it picked that you ran: Redirected / Helped / Useful), the plugin hands you a receipt once. End
your reply with it exactly as written. It is read from the session's tool calls, not from what you write, so there is
nothing to claim yourself; if the reply does not carry it, the user is shown it anyway.
Each repository keeps its own record (`.axiomcode/assists.jsonl` at its root) and its own page,
`.axiomcode/assists.html`, rebuilt whenever a turn adds to the record; `axiomcode report` rebuilds it by hand.

## graph

`axiomcode graph` draws the codebase as one page for the user to look at: directories, files, types and methods, with
the call edges between them and each edge's tier. It prints the page's path (`.axiomcode/graph/graph.html`); open it
in a browser. It is drawn from the graph the index built, rebuilt first only when a source file changed. It is not an
MCP tool: it answers no question, so when the user asks to see the graph, run it and give them the path.

## What it cannot see — say so instead of guessing

Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a
Expand Down
Loading
Loading