Skip to content
Open
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
13 changes: 10 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,15 +294,22 @@ function loadHeartbeatConfig(raw: MexPersistedConfig | null): HeartbeatConfig |
}
const h = raw.heartbeat as Record<string, unknown>;
const out: HeartbeatConfig = {};
const staleDays = readPositiveNumber(h.staleDays);
const memoryCleanupDays = readPositiveNumber(h.memoryCleanupDays);
const dailyMemoryRetentionDays = readPositiveNumber(h.dailyMemoryRetentionDays);
// Day-based heartbeat thresholds accept 0 ("stale as soon as older than
// today", #42) while still rejecting negatives and garbage.
const staleDays = readDayThreshold(h.staleDays);
const memoryCleanupDays = readDayThreshold(h.memoryCleanupDays);
const dailyMemoryRetentionDays = readDayThreshold(h.dailyMemoryRetentionDays);
if (staleDays !== undefined) out.staleDays = staleDays;
if (memoryCleanupDays !== undefined) out.memoryCleanupDays = memoryCleanupDays;
if (dailyMemoryRetentionDays !== undefined) out.dailyMemoryRetentionDays = dailyMemoryRetentionDays;
return Object.keys(out).length ? out : undefined;
}

function readDayThreshold(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v;
return undefined;
}

function readPositiveNumber(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
return undefined;
Expand Down
6 changes: 5 additions & 1 deletion src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ export async function runDoctor(config: MexConfig): Promise<void> {

printLine("Drift", report.score >= 80 && errors === 0, `${report.score}/100 (${errors} errors, ${warnings} warnings)`);
printLine("Graph", graph.status === "fresh", graphStatusDetail(graph));
printLine("Heartbeat", heartbeat.ok, heartbeat.ok ? "HEARTBEAT_OK" : `${heartbeat.staleFiles.length} stale files, ${heartbeat.oldDailyMemoryFiles.length} old memory files`);
printLine("Heartbeat", heartbeat.ok, heartbeat.ok
? (heartbeat.filesWithoutLastUpdated
? "HEARTBEAT_OK (staleness checks inactive: no last_updated fields)"
: "HEARTBEAT_OK")
: `${heartbeat.staleFiles.length} stale files, ${heartbeat.oldDailyMemoryFiles.length} old memory files`);
printLine("Events", true, `${events.length} logged event${events.length === 1 ? "" : "s"}`);
const hasConfig = existsSync(resolve(config.scaffoldRoot, "config.json"));
printLine("Config", true, hasConfig ? ".mex/config.json loaded with defaults for missing values" : "using defaults; no .mex/config.json found");
Expand Down
23 changes: 18 additions & 5 deletions src/drift/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs";
import { readFileSync, realpathSync } from "node:fs";
import { resolve, relative } from "node:path";
import { globSync } from "glob";
import type { MexConfig, DriftReport, DriftIssue, Claim } from "../types.js";
Expand Down Expand Up @@ -419,15 +419,28 @@ export function findScaffoldFiles(
): string[] {
const files: string[] = [];

// Search inside scaffold root (handles both .mex/ and root layouts)
// Search inside scaffold root (handles both .mex/ and root layouts).
// follow: true supports symlinked scaffold content; deduplicating by real
// path keeps one file reached through two links a single scan entry, and
// glob bounds symlink loops so runaway scans stay off the table (#40).
const seenReal = new Set<string>();
for (const pattern of patterns) {
const matches = globSync(pattern, {
for (const match of globSync(pattern, {
cwd: scaffoldRoot,
absolute: true,
follow: true,
ignore: ["node_modules/**"],
});
files.push(...matches);
})) {
let real: string;
try {
real = realpathSync(match);
} catch {
real = match;
}
if (seenReal.has(real)) continue;
seenReal.add(real);
files.push(match);
}
}

// Also check project root for tool config files (CLAUDE.md, etc.)
Expand Down
53 changes: 50 additions & 3 deletions src/heartbeat.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { resolve, relative } from "node:path";
import { globSync } from "glob";
import chalk from "chalk";
Expand All @@ -12,6 +12,15 @@ export interface HeartbeatResult {
staleFiles: Array<{ file: string; days: number }>;
memoryCleanupDue: boolean;
oldDailyMemoryFiles: string[];
/**
* Additive: number of scaffold files the heartbeat scanned that carry no
* parseable `last_updated` at all. When every scanned file lacks one,
* staleness checks are silently inert — the CLI surfaces a hint instead of
* reporting a healthy-looking sweep that checked nothing. Absent (0) for
* scaffolds where at least one file opts in, so existing JSON consumers
* see no change.
*/
filesWithoutLastUpdated?: number;
}

export interface HeartbeatOpts {
Expand Down Expand Up @@ -63,9 +72,13 @@ export function checkHeartbeat(
const memoryCleanupDays = config.heartbeat?.memoryCleanupDays ?? DEFAULT_MEMORY_CLEANUP_DAYS;
const dailyRetentionDays = config.heartbeat?.dailyMemoryRetentionDays ?? DEFAULT_DAILY_MEMORY_RETENTION_DAYS;

let scanned = 0;
let withLastUpdated = 0;
const staleFiles = scaffoldHeartbeatFiles(config.scaffoldRoot, opts.scaffoldPatterns)
.map((file) => {
const fm = parseFrontmatter(file);
scanned++;
if (typeof fm?.last_updated === "string") withLastUpdated++;
const days = daysSinceFrontmatterDate(
typeof fm?.last_updated === "string" ? fm.last_updated : undefined,
now,
Expand All @@ -84,21 +97,39 @@ export function checkHeartbeat(
staleFiles,
memoryCleanupDue,
oldDailyMemoryFiles,
...(scanned > 0 && withLastUpdated === 0 ? { filesWithoutLastUpdated: scanned } : {}),
};
}

function scaffoldHeartbeatFiles(
scaffoldRoot: string,
patterns: readonly string[] = DEFAULT_HEARTBEAT_PATTERNS,
): string[] {
return patterns.flatMap((pattern) =>
// follow: true supports symlinked scaffold content, and deduplicating by
// real path keeps a file reached through two links a single heartbeat
// entry; glob itself bounds symlink loops, so runaway scans stay off the
// table (#40).
const seen = new Set<string>();
const files: string[] = [];
for (const file of patterns.flatMap((pattern) =>
globSync(pattern, {
cwd: scaffoldRoot,
absolute: true,
follow: true,
nodir: true,
}),
);
)) {
let real: string;
try {
real = realpathSync(file);
} catch {
real = file;
}
if (seen.has(real)) continue;
seen.add(real);
files.push(file);
}
return files;
}

function isMemoryCleanupDue(projectRoot: string, thresholdDays: number, now: Date): boolean {
Expand Down Expand Up @@ -137,11 +168,27 @@ function daysSinceIsoDate(value: string, now: Date): number | null {

function printHeartbeat(result: HeartbeatResult, config: MexConfig): void {
if (result.ok) {
if (result.filesWithoutLastUpdated) {
console.log("HEARTBEAT_OK");
console.log();
console.log(
chalk.dim(`No scaffold files include last_updated; staleness checks are currently skipped. `
+ `Add last_updated: YYYY-MM-DD to frontmatter to opt files in.`),
);
return;
}
console.log("HEARTBEAT_OK");
return;
}

console.log(chalk.bold("Heartbeat needs attention"));
if (result.filesWithoutLastUpdated) {
console.log();
console.log(
chalk.dim(`No scaffold files include last_updated; staleness checks are currently skipped. `
+ `Add last_updated: YYYY-MM-DD to frontmatter to opt files in.`),
);
}
if (result.staleFiles.length) {
console.log();
console.log(chalk.yellow("Stale scaffold files:"));
Expand Down
16 changes: 13 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,21 @@ export interface WatchConfig {
}

export interface HeartbeatConfig {
/** Days since `last_updated` before heartbeat reports stale context */
/**
* Days since `last_updated` before heartbeat reports stale context. `0`
* means stale as soon as a file is older than today (#42); negatives and
* other garbage fall back to the default.
*/
staleDays?: number;
/** Days since memory cleanup before heartbeat reports cleanup due */
/**
* Days since memory cleanup before heartbeat reports cleanup due. `0`
* means due every day; negatives fall back to the default.
*/
memoryCleanupDays?: number;
/** Daily memory files older than this are considered cleanup candidates */
/**
* Daily memory files older than this are considered cleanup candidates.
* `0` flags yesterday's file; negatives fall back to the default.
*/
dailyMemoryRetentionDays?: number;
}

Expand Down
8 changes: 7 additions & 1 deletion test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ describe("findConfig — watch and heartbeat config", () => {
});
});

it("ignores non-positive watch and heartbeat values", () => {
it("accepts zero for day-based heartbeat thresholds but rejects negatives (#42)", () => {
setupConfig({ heartbeat: { staleDays: 0, memoryCleanupDays: 0, dailyMemoryRetentionDays: -3 } });
const config = findConfig(tmpDir);
expect(config.heartbeat).toEqual({ staleDays: 0, memoryCleanupDays: 0 });
});

it("ignores negative watch and heartbeat values", () => {
setupConfig({ watch: { intervalMinutes: 0 }, heartbeat: { staleDays: -1 } });
const config = findConfig(tmpDir);
expect(config.watch).toBeUndefined();
Expand Down
81 changes: 81 additions & 0 deletions test/heartbeat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,89 @@ describe("heartbeat", () => {
expect(result.memoryCleanupDue).toBe(true);
expect(result.oldDailyMemoryFiles).toEqual(["memory/2026-04-20.md"]);
});

it("reports zero participating files when no scaffold file opts into staleness (#41)", () => {
rmSync(join(tmpDir, ".mex/ROUTER.md"));
writeFileSync(join(tmpDir, ".mex/ROUTER.md"), "---\nname: router\n---\n\n# Router\n");
const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z"));
expect(result.ok).toBe(true);
expect(result.filesWithoutLastUpdated).toBe(1);
});

it("omits filesWithoutLastUpdated once any file opts in", () => {
writeFileSync(join(tmpDir, ".mex/context/architecture.md"), "---\nname: architecture\n---\n\nno date here\n");
const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z"));
expect(result.filesWithoutLastUpdated).toBeUndefined();
});

it("keeps staleness active and the field absent when files carry dates", () => {
const result = checkHeartbeat(config, new Date("2026-05-14T00:00:00Z"));
expect(result.filesWithoutLastUpdated).toBeUndefined();
expect(result.staleFiles).toEqual([]);
});
});

function frontmatter(name: string, lastUpdated: string): string {
return `---\nname: ${name}\nlast_updated: ${lastUpdated}\n---\n\n# ${name}\n`;
}

describe("zero-day heartbeat thresholds (#42)", () => {
let zeroTmp: string;
let zeroConfig: MexConfig;

beforeEach(() => {
zeroTmp = mkdtempSync(join(tmpdir(), "mex-heartbeat-zero-"));
mkdirSync(join(zeroTmp, ".mex/context"), { recursive: true });
zeroConfig = {
projectRoot: zeroTmp,
scaffoldRoot: join(zeroTmp, ".mex"),
aiTools: [],
heartbeat: { staleDays: 0, memoryCleanupDays: 0, dailyMemoryRetentionDays: 0 },
};
});

afterEach(() => {
rmSync(zeroTmp, { recursive: true, force: true });
});

it("flags a file dated yesterday as stale when staleDays is 0", () => {
writeFileSync(join(zeroTmp, ".mex/ROUTER.md"), frontmatter("router", "2026-05-13"));
const result = checkHeartbeat(zeroConfig, new Date("2026-05-14T00:00:00Z"));
expect(result.staleFiles.map((f) => f.file)).toContain("ROUTER.md");
});

it("does not flag a file dated today when staleDays is 0", () => {
writeFileSync(join(zeroTmp, ".mex/ROUTER.md"), frontmatter("router", "2026-05-14"));
const result = checkHeartbeat(zeroConfig, new Date("2026-05-14T00:00:00Z"));
expect(result.staleFiles).toEqual([]);
expect(result.ok).toBe(true);
});
});

describe("symlinked scaffold files (#40)", () => {
it("counts a file reached through two glob paths exactly once", () => {
const root = mkdtempSync(join(tmpdir(), "mex-heartbeat-symlink-"));
try {
mkdirSync(join(root, ".mex/context"), { recursive: true });
mkdirSync(join(root, ".mex/extra"), { recursive: true });
const real = join(root, ".mex/context/architecture.md");
writeFileSync(real, frontmatter("architecture", "2026-05-01"));
// Same content reachable through a second pattern's directory.
let linked = true;
try {
symlinkSync(real, join(root, ".mex/extra/architecture.md"));
} catch {
linked = false; // Windows without symlink privilege: skip assert
}
const result = checkHeartbeat({
projectRoot: root,
scaffoldRoot: join(root, ".mex"),
aiTools: [],
}, new Date("2026-05-14T00:00:00Z"));
if (!linked) return;
expect(result.staleFiles.filter((f) => f.file.endsWith("architecture.md"))).toHaveLength(1);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading