Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/route-graph-ir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"agent-bundle": minor
---

Compile the conventional route tree into an immutable route-graph IR (#93, PR-1) and expose it through `agent-bundle inspect --routes`.

- Discovery covers `src/mcp/<server>/{tools,resources,prompts,apps}/*.{ts,tsx}` (direct children per MCP kind), `src/events/<family>/*.{ts,tsx}` (`event:<family>/<name>`), `src/providers/*.{ts,tsx}` (a separate provider collection), and nested `src/cli/**` / `src/scripts/**` identities. Project ignore rules, private `_`/`.` segments, and `*.d.ts` files are skipped. Modules referenced by explicit `scripts`/`hooks`/`bin`/`lib`/`mcp` configuration are claimed by that declaration and never become routes, so existing layouts (for example `scripts` entries under `src/scripts/`) stay route-free without a migration.
- The graph is deep-frozen, every route carries `config: {}` until the config extractor lands (PR-2), and the graph digest covers project-relative identity only, so equal trees hash equally on every machine.
- Collisions are hard errors, never silent choices: `AB4800` (routed MCP server vs existing entry claim), `AB4801` (`src/cli.ts` vs `src/cli/`), `AB4802` (duplicate route id), `AB4803` (unsafe identity segment), `AB4804` (invalid `routes` mode override). Explicit `routes.servers.<id>` (`generated`/`custom`/`command`/`remote`) and `routes.cli` (`generated`/`conventional`) overrides resolve conflicts; without one, the conflicting surface keeps its discovered routes in `conflict` mode beside the error.
- `discoverProject` attaches the graph only when it is non-empty, `validate` surfaces its diagnostics, and `inspect({ focus: 'routes' })` / `agent-bundle inspect --routes` dump the compiled graph like the bundler focus. `CapabilityState`/`CapabilityEvidence` types ship with the IR; population follows with the host-component work (#100).
19 changes: 19 additions & 0 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,25 @@ simply not been built yet is a validation **warning** that only
| `AB4749` | error (build) | A payload directory overlaps the artifact `--output` root. |
| `AB4750` | info | A payload is older than the newest project source file and may be stale; rerun the project's own build if so. |

## Route graph (`AB4800`–`AB4804`)

The route-graph compiler discovers conventional route modules
(`src/mcp/<server>/{tools,resources,prompts,apps}/*`, `src/events/*/*`,
`src/providers/*`, `src/cli/**`, `src/scripts/**`) into one immutable IR.
Discovery is not a packaging choice, so every collision is a hard **error**
and the compiler never silently picks a side. Modules that explicit
`scripts`, `hooks`, `bin`, `lib`, or `mcp` configuration references are
claimed by that declaration and never become routes — config always wins.
`agent-bundle inspect --routes` dumps the compiled graph.

| Code | Severity | Trigger |
| --- | --- | --- |
| `AB4800` | error | An MCP server has both discovered route modules under `src/mcp/<id>/` and an existing entry claim (the conventional `src/mcp/<id>.ts` module, or a declared `entry`/`command`/`url`) without an explicit `routes.servers.<id>` mode. |
| `AB4801` | error | The conventional `src/cli.ts` entry and `src/cli/` command route modules both exist without an explicit `routes.cli` mode. |
| `AB4802` | error | Two route modules derive the same route id (for example `.ts` and `.tsx` siblings with one stem). |
| `AB4803` | error | A route path derives an unsafe identity segment (each segment must match `^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$`). |
| `AB4804` | error | A `routes` mode override is not `generated`/`custom`/`command`/`remote` for a server, or `generated`/`conventional` for the CLI. |

## Development package build (`AB7103`)

`agent-bundle dev` rebuilds the framework-owned package build (`dist/` bin
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Date: 2026-08-25
Status: proposed

> Status note (2026-08-31): the nine-page Workbench is in tree. The remaining
> manifest navigation, Agent Document stage, replay, and read-only discovery
> work is tracked in
> [#105](https://github.com/ScriptedAlchemy/agent-bundle/issues/105). This
> spec is not the live execution plan.

## Context

The Workbench currently renders the same navigation and workflow for every
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,4 @@ Real-world acceptance includes:
- Shipping media, cached Audible payloads, credentials, Whisper models, or Python environments.
- Adding hooks without a concrete curator lifecycle need.
- Building a filesystem router or general web framework when a declarative application tree and shared operation registry suffice.
- Superseded (2026-08-31) by [#93](https://github.com/ScriptedAlchemy/agent-bundle/issues/93): the filesystem-router non-goal is withdrawn. The route compiler is planned. Public authoring waits for the wave-3 renderer join ([#107](https://github.com/ScriptedAlchemy/agent-bundle/issues/107)). The rest of these non-goals are unchanged.
30 changes: 29 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,26 @@ import type { TargetArtifactEntry, TargetHookEntry } from './adapters/types.ts';
import { build as buildArtifact, type BuildResult } from './build/build.ts';
import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts';
import { isInsideOrEqual } from './core/paths.ts';
import { emptyCompiledRouteGraph } from './routes/graph.ts';
import { inspectRouteGraph, type RouteGraphInspection } from './routes/inspect.ts';
import { mcpServerStateDirectory, runMcpForeground } from './services/mcp-run.ts';
export { compileRouteGraph, emptyCompiledRouteGraph, isEmptyRouteGraph } from './routes/graph.ts';
export { inspectRouteGraph } from './routes/inspect.ts';
export type { RouteGraphInspection } from './routes/inspect.ts';
export { emptyRouteConfig } from './routes/types.ts';
export type {
CapabilityEvidence,
CapabilityState,
CompiledAgentRoute,
CompiledCliMode,
CompiledCliSurface,
CompiledProvider,
CompiledRouteGraph,
CompiledRouteKind,
CompiledServerMode,
CompiledServerSurface,
RouteProvenance,
} from './routes/types.ts';
export type { BuildResult } from './build/build.ts';
export type { PackageBuildResult, PackageOutputFile } from './build/package-build.ts';
export type { ArtifactOutputKind, ArtifactOutputProvenance } from './build/provenance.ts';
Expand Down Expand Up @@ -199,7 +218,7 @@ export interface InspectionPlan {
}

export interface InspectOptions extends ProjectOptions {
readonly focus?: 'bundler' | 'hooks' | 'skills';
readonly focus?: 'bundler' | 'hooks' | 'routes' | 'skills';
readonly target?: string;
}

Expand All @@ -211,6 +230,7 @@ export interface ReadyInspectResult {
readonly selected?: {
readonly bundler?: BundlerInspection;
readonly hooks?: NormalizedPlugin['hooks'];
readonly routes?: RouteGraphInspection;
readonly skills?: NormalizedPlugin['skills'];
};
readonly state: 'ready';
Expand Down Expand Up @@ -472,11 +492,19 @@ export const inspect = async (options: InspectOptions): Promise<InspectResult> =
]));
}
}
// The route focus serves the graph preparation already compiled during
// discovery — never a second configuration evaluation, so the focus can
// not diverge from the validated model. Route-free projects attach no
// graph and serve the shared empty one.
const routes: RouteGraphInspection | undefined = options.focus === 'routes'
? inspectRouteGraph(prepared.routeGraph ?? emptyCompiledRouteGraph)
: undefined;
const selected = options.focus === undefined
? undefined
: Object.freeze({
...(bundler === undefined ? {} : { bundler }),
...(options.focus === 'hooks' ? { hooks: model.hooks } : {}),
...(routes === undefined ? {} : { routes }),
...(options.focus === 'skills' ? { skills: model.skills } : {}),
});
return Object.freeze({
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ interface InspectCommandOptions {
readonly json?: boolean;
readonly mode?: string;
readonly root: string;
readonly routes?: boolean;
readonly skills?: boolean;
readonly target?: string;
}
Expand Down Expand Up @@ -215,6 +216,12 @@ const writeHumanInspect = (output: Output, result: Awaited<ReturnType<typeof ins
output.write(`${JSON.stringify(result.selected.bundler, null, 2)}\n`);
return;
}
if (result.selected?.routes !== undefined) {
// The route focus follows the bundler contract: the compiled graph is
// the human output, not a one-line summary.
output.write(`${JSON.stringify(result.selected.routes, null, 2)}\n`);
return;
}
output.write(`Inspected ${result.model.metadata.name}: ${result.plans.map((plan) => plan.target).join(', ')}\n`);
// Release identity is derived from package.json (issue #94); a project
// without a package version gets a clearly labeled development fallback.
Expand Down Expand Up @@ -416,9 +423,10 @@ export const runCli = async (
)
.option('--bundler', 'Include the synthesized bundler configuration focus')
.option('--hooks', 'Include the hook focus')
.option('--routes', 'Include the compiled route-graph focus')
.option('--skills', 'Include the skill focus');
inspectCommand.action(async (options: InspectCommandOptions) => {
const focuses = [options.bundler, options.hooks, options.skills].filter((focus) => focus === true);
const focuses = [options.bundler, options.hooks, options.routes, options.skills].filter((focus) => focus === true);
if (focuses.length > 1) {
throw new TypeError('Choose at most one inspect focus.');
}
Expand All @@ -427,6 +435,7 @@ export const runCli = async (
...inspectProjectOptions(options),
...(options.bundler === true ? { focus: 'bundler' as const } : {}),
...(options.hooks === true ? { focus: 'hooks' as const } : {}),
...(options.routes === true ? { focus: 'routes' as const } : {}),
...(options.skills === true ? { focus: 'skills' as const } : {}),
...(options.target === undefined ? {} : { target: options.target }),
});
Expand Down
10 changes: 10 additions & 0 deletions packages/agent-bundle/src/config/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import fastGlob from 'fast-glob';
import { isInside } from '../core/paths.ts';
import { isRecord } from '../core/strict-json.ts';
import type { AgentBundleConfig } from '../core/types.ts';
import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts';
import type { CompiledRouteGraph } from '../routes/types.ts';
import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts';
import { isRenderedSkillSourceName } from './rendered-skill.ts';
import { parseSkill, type SkillDocument } from './skill.ts';
Expand Down Expand Up @@ -38,6 +40,12 @@ export interface DiscoveredPayload {
export interface DiscoveredProject {
assets?: DiscoveredAsset[];
payloads?: DiscoveredPayload[];
/**
* The compiled conventional route graph (#93). Present only when route
* discovery found modules or produced diagnostics, so route-free projects
* keep their existing discovery shape.
*/
routeGraph?: CompiledRouteGraph;
/**
* Conventional `skills/<name>/SKILL.md` documents that explicit `skills`
* configuration leaves uncovered — the confusable shadowed state surfaced
Expand Down Expand Up @@ -229,9 +237,11 @@ export const discoverProject = async (
const shadowedConventionalSkills = [...shadowedByDir.values()];

const payloads = await discoverPayloads(projectRoot, config.payload);
const routeGraph = await compileRouteGraph(projectRoot, config, rules);
return {
assets: await discoverAssets(projectRoot, config.assets, rules),
...(payloads.length === 0 ? {} : { payloads }),
...(routeGraph === undefined || isEmptyRouteGraph(routeGraph) ? {} : { routeGraph }),
...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }),
skills: await Promise.all(
skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)),
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1459,6 +1459,12 @@ export const validateSource = (
diagnostics.push(...validateTools(loaded));
diagnostics.push(...packageConventionShadowNudges(loaded));
diagnostics.push(...skillConventionShadowNudges(loaded, discovered));
// Route overrides are validated during discovery; source validation must
// still observe the config getter so hostile accessors fail closed as AB7001.
void loaded.config['routes'];
// Route-graph collisions (AB4800-AB4804) are compiled during discovery;
// they are project-source errors, so they gate inspect and build here.
diagnostics.push(...(discovered.routeGraph?.diagnostics ?? []));

return diagnostics;
};
Expand Down
28 changes: 27 additions & 1 deletion packages/agent-bundle/src/dev/project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
NormalizedMcpServer,
NormalizedPlugin,
} from '../core/types.ts';
import type { CompiledRouteGraph } from '../routes/types.ts';
import type { DevRuntimePreparedMcpApp, DevRuntimePreparedMcpServer, DevRuntimePreparedProject } from './runtime-provider.ts';
import { freezeJsonValue, type JsonObject, type JsonValue, type SourceStatus } from './types.ts';

Expand Down Expand Up @@ -60,6 +61,12 @@ export interface PreparedProject {
readonly projectContext?: ProjectContext;
readonly registry: TargetRegistry;
readonly root: string;
/**
* The compiled route graph discovery attached (#93); absent when no
* conventional route module exists. Carried through preparation so inspect
* never re-evaluates the configuration for the routes focus.
*/
readonly routeGraph?: CompiledRouteGraph;
/**
* Re-snapshots the project with the same output and payload roots the
* prepared identity hashed; a divergent re-snapshot would make
Expand Down Expand Up @@ -548,6 +555,7 @@ const preparedProject = (
devRuntimeDiagnostic?: Diagnostic,
devAgentApiEnabled?: boolean,
tools?: AgentBundleToolsConfig,
routeGraph?: CompiledRouteGraph,
): PreparedProject => Object.freeze({
configPath,
...(devAgentApiEnabled === true ? { devAgentApiEnabled } : {}),
Expand All @@ -559,6 +567,7 @@ const preparedProject = (
...(projectContext === undefined ? {} : { projectContext }),
registry,
root,
...(routeGraph === undefined ? {} : { routeGraph }),
snapshotSource,
source,
...(tools === undefined ? {} : { tools }),
Expand Down Expand Up @@ -753,7 +762,23 @@ export class ProjectService {
if (hasErrors(sourceDiagnostics)) {
const source = sourceStatus(sourceDiagnostics, snapshot.revision, root);
log(this.#options.logger, 'project.invalid-source', { diagnostics: sourceDiagnostics.length, root });
return preparedProject(loaded.configPath, snapshot, sourceDiagnostics, outputRoots, undefined, registry, root, source, snapshotSource);
return preparedProject(
loaded.configPath,
snapshot,
sourceDiagnostics,
outputRoots,
undefined,
registry,
root,
source,
snapshotSource,
undefined,
undefined,
undefined,
undefined,
undefined,
discovered.routeGraph,
);
}

let model: NormalizedPlugin;
Expand Down Expand Up @@ -868,6 +893,7 @@ export class ProjectService {
devRuntimeDiagnostic,
devAgentApiEnabled,
tools,
discovered.routeGraph,
);
}
}
Loading
Loading