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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ result

# OS
Thumbs.db

# Per-user Claude Code config
.claude/
9 changes: 9 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
"version": "0.3.5",
"description": "CLI engine for mex scaffold — drift detection, pre-analysis, and targeted sync",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"bin": {
"mex": "./dist/cli.js"
},
Expand Down
45 changes: 44 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,51 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { resolve, dirname, isAbsolute } from "node:path";
import type { MexConfig, AiTool, StalenessThresholds, WatchConfig, HeartbeatConfig } from "./types.js";
import { DEFAULT_STALENESS_THRESHOLDS } from "./drift/checkers/staleness.js";

/**
* Inputs accepted by {@link createConfig}. Only the two roots are required —
* everything else mirrors the optional fields on {@link MexConfig} so callers
* can opt in field by field.
*/
export interface CreateConfigInput {
/** Absolute path to the project root (e.g. where .git lives). */
projectRoot: string;
/** Absolute path to the scaffold root (the directory holding ROUTER.md, etc.). */
scaffoldRoot: string;
aiTools?: AiTool[];
stalenessThresholds?: StalenessThresholds;
watch?: WatchConfig;
heartbeat?: HeartbeatConfig;
}

/**
* Build a {@link MexConfig} from explicit inputs, bypassing the on-disk
* discovery that {@link findConfig} performs. Intended for embedders that
* already know where the project and scaffold live — for example, tools that
* use a non-default scaffold directory name and therefore can't rely on
* findConfig's `.mex/` lookup.
*
* Defaults `aiTools` to an empty array. Other optional fields are passed
* through untouched.
*/
export function createConfig(input: CreateConfigInput): MexConfig {
if (!isAbsolute(input.projectRoot)) {
throw new Error(`createConfig: projectRoot must be an absolute path, got "${input.projectRoot}"`);
}
if (!isAbsolute(input.scaffoldRoot)) {
throw new Error(`createConfig: scaffoldRoot must be an absolute path, got "${input.scaffoldRoot}"`);
}
return {
projectRoot: input.projectRoot,
scaffoldRoot: input.scaffoldRoot,
aiTools: input.aiTools ?? [],
stalenessThresholds: input.stalenessThresholds,
watch: input.watch,
heartbeat: input.heartbeat,
};
}

/**
* Walk up from startDir looking for .git to find project root,
* then look for scaffold root (.mex/ or context/ directory).
Expand Down
48 changes: 35 additions & 13 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,45 @@ import { checkCrossFile } from "./checkers/cross-file.js";
import { checkScriptCoverage } from "./checkers/script-coverage.js";
import { checkToolConfigSync } from "./checkers/tool-config-sync.js";

/**
* Default glob patterns used to locate scaffold markdown files, relative to
* `MexConfig.scaffoldRoot`. Exported so consumers can extend rather than
* replace the list, e.g.
*
* ```ts
* runDriftCheck(config, {
* scaffoldPatterns: [...DEFAULT_SCAFFOLD_PATTERNS, "traces/**\/*.md"],
* });
* ```
*
* NOT a stable contract — mex may add to this list between minor versions.
* If exact behavior matters, pass `scaffoldPatterns` explicitly.
*/
export const DEFAULT_SCAFFOLD_PATTERNS = [
"context/*.md",
"patterns/*.md",
"ROUTER.md",
"AGENTS.md",
"SETUP.md",
"SYNC.md",
] as const;

export interface RunDriftCheckOpts {
verbose?: boolean;
/** Override the glob patterns used to discover scaffold files (relative to
* `config.scaffoldRoot`). Defaults to {@link DEFAULT_SCAFFOLD_PATTERNS}. */
scaffoldPatterns?: readonly string[];
}

/** Run full drift detection across all scaffold files */
export async function runDriftCheck(
config: MexConfig,
opts: { verbose?: boolean } = {}
opts: RunDriftCheckOpts = {}
): Promise<DriftReport> {
const { projectRoot, scaffoldRoot } = config;

// Find all markdown files in scaffold
const scaffoldFiles = findScaffoldFiles(projectRoot, scaffoldRoot);
const scaffoldFiles = findScaffoldFiles(projectRoot, scaffoldRoot, opts.scaffoldPatterns);
const allClaims: Claim[] = [];
const allIssues: DriftIssue[] = [];
const checkerIssueCounts: Array<[string, number]> = [];
Expand Down Expand Up @@ -106,21 +136,13 @@ export async function runDriftCheck(
/** Find all markdown files that are part of the scaffold */
function findScaffoldFiles(
projectRoot: string,
scaffoldRoot: string
scaffoldRoot: string,
patterns: readonly string[] = DEFAULT_SCAFFOLD_PATTERNS
): string[] {
const scaffoldPatterns = [
"context/*.md",
"patterns/*.md",
"ROUTER.md",
"AGENTS.md",
"SETUP.md",
"SYNC.md",
];

const files: string[] = [];

// Search inside scaffold root (handles both .mex/ and root layouts)
for (const pattern of scaffoldPatterns) {
for (const pattern of patterns) {
const matches = globSync(pattern, {
cwd: scaffoldRoot,
absolute: true,
Expand Down
8 changes: 6 additions & 2 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { dirname, resolve, relative } from "node:path";
import chalk from "chalk";
import type { MexConfig } from "./types.js";

export type EventKind = "decision" | "note" | "risk" | "todo";
/** Runtime list of valid event kinds. Re-exported as part of the public API so
* consumers can validate user-supplied kinds against the same source of truth. */
export const EVENT_KINDS = ["decision", "note", "risk", "todo"] as const;

export type EventKind = (typeof EVENT_KINDS)[number];

export interface EventEntry {
timestamp: string;
Expand All @@ -25,7 +29,7 @@ export interface TimelineOpts {
limit?: number;
}

const VALID_KINDS = new Set<EventKind>(["decision", "note", "risk", "todo"]);
const VALID_KINDS = new Set<EventKind>(EVENT_KINDS);
const EVENT_FILE = "events/decisions.jsonl";

export function eventLogPath(config: MexConfig): string {
Expand Down
38 changes: 33 additions & 5 deletions src/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,30 @@ export interface HeartbeatResult {

export interface HeartbeatOpts {
json?: boolean;
/** Override the glob patterns used to discover heartbeat files (relative to
* `config.scaffoldRoot`). Defaults to {@link DEFAULT_HEARTBEAT_PATTERNS}. */
scaffoldPatterns?: readonly string[];
}

/**
* Default glob patterns used by the heartbeat checker, relative to
* `MexConfig.scaffoldRoot`. NOT a stable contract — mex may add to this list
* between minor versions. Pass `scaffoldPatterns` explicitly when exact
* behavior matters.
*/
export const DEFAULT_HEARTBEAT_PATTERNS = [
"ROUTER.md",
"AGENTS.md",
"context/*.md",
"patterns/*.md",
] as const;

const DEFAULT_STALE_DAYS = 7;
const DEFAULT_MEMORY_CLEANUP_DAYS = 7;
const DEFAULT_DAILY_MEMORY_RETENTION_DAYS = 14;

export async function runHeartbeat(config: MexConfig, opts: HeartbeatOpts = {}): Promise<HeartbeatResult> {
const result = checkHeartbeat(config);
const result = checkHeartbeat(config, new Date(), { scaffoldPatterns: opts.scaffoldPatterns });
if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return result;
Expand All @@ -31,12 +47,22 @@ export async function runHeartbeat(config: MexConfig, opts: HeartbeatOpts = {}):
return result;
}

export function checkHeartbeat(config: MexConfig, now = new Date()): HeartbeatResult {
export interface CheckHeartbeatOpts {
/** Override the glob patterns used to discover heartbeat files (relative to
* `config.scaffoldRoot`). Defaults to {@link DEFAULT_HEARTBEAT_PATTERNS}. */
scaffoldPatterns?: readonly string[];
}

export function checkHeartbeat(
config: MexConfig,
now = new Date(),
opts: CheckHeartbeatOpts = {}
): HeartbeatResult {
const staleDays = config.heartbeat?.staleDays ?? DEFAULT_STALE_DAYS;
const memoryCleanupDays = config.heartbeat?.memoryCleanupDays ?? DEFAULT_MEMORY_CLEANUP_DAYS;
const dailyRetentionDays = config.heartbeat?.dailyMemoryRetentionDays ?? DEFAULT_DAILY_MEMORY_RETENTION_DAYS;

const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot)
const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot, opts.scaffoldPatterns)
.map((file) => {
const fm = parseFrontmatter(file);
const days = daysSinceFrontmatterDate(
Expand All @@ -60,8 +86,10 @@ export function checkHeartbeat(config: MexConfig, now = new Date()): HeartbeatRe
};
}

function scaffoldHeartbeatFiles(scaffoldRoot: string): string[] {
const patterns = ["ROUTER.md", "AGENTS.md", "context/*.md", "patterns/*.md"];
function scaffoldHeartbeatFiles(
scaffoldRoot: string,
patterns: readonly string[] = DEFAULT_HEARTBEAT_PATTERNS,
): string[] {
return patterns.flatMap((pattern) =>
globSync(pattern, {
cwd: scaffoldRoot,
Expand Down
61 changes: 61 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Public API for `mex-agent`.
*
* Everything re-exported from this file is part of the package's compatibility
* contract — removing or reshaping any of these is a breaking change.
*
* Internal modules (`src/cli.ts`, `src/sync/`, `src/scanner/`, `src/setup/`,
* `src/tui.ts`, `src/watch.ts`, `src/doctor.ts`, etc.) are NOT part of the
* contract and may change without notice. Import only from `"mex-agent"`.
*/

// ── Config ───────────────────────────────────────────────────────────────────
export { findConfig, createConfig } from "./config.js";
export type { CreateConfigInput } from "./config.js";

// ── Events (append-only JSONL log) ───────────────────────────────────────────
export {
appendEvent,
readEvents,
eventLogPath,
EVENT_KINDS,
} from "./events.js";
export type { EventEntry, EventKind } from "./events.js";

// ── Drift detection ──────────────────────────────────────────────────────────
export {
runDriftCheck,
DEFAULT_SCAFFOLD_PATTERNS,
} from "./drift/index.js";
export type { RunDriftCheckOpts } from "./drift/index.js";
export { parseFrontmatter } from "./drift/frontmatter.js";
export { DEFAULT_STALENESS_THRESHOLDS } from "./drift/checkers/staleness.js";

// ── Heartbeat (scaffold staleness + memory cleanup) ──────────────────────────
export {
checkHeartbeat,
runHeartbeat,
DEFAULT_HEARTBEAT_PATTERNS,
} from "./heartbeat.js";
export type {
HeartbeatResult,
HeartbeatOpts,
CheckHeartbeatOpts,
} from "./heartbeat.js";

// ── Shared types ─────────────────────────────────────────────────────────────
export type {
AiTool,
MexConfig,
StalenessThresholds,
WatchConfig,
HeartbeatConfig,
ScaffoldFrontmatter,
FrontmatterEdge,
DriftReport,
DriftIssue,
IssueCode,
Severity,
Claim,
ClaimKind,
} from "./types.js";
Loading
Loading