From 4252aec4b68a3b71f565a86b57a2c5a70dcbf14c Mon Sep 17 00:00:00 2001 From: blocks Date: Thu, 19 Mar 2026 00:03:22 +0800 Subject: [PATCH] refactor: purge internal review artifacts and harden review flows --- .gitignore | 8 + README.en.md | 6 +- README.md | 6 +- docs/README.en.md | 18 +- docs/README.md | 18 +- docs/release-checklist.md | 7 +- docs/session-continuity.md | 9 +- src/lib/commands/audit.ts | 18 +- src/lib/commands/memory.ts | 67 ++++- src/lib/commands/session.ts | 72 +++++- src/lib/domain/reviewer-history.ts | 50 ++++ src/lib/security/audit.ts | 98 ++++++- test/audit.test.ts | 179 ++++++++++++- test/memory-command.test.ts | 153 +++++++++++ test/memory-sync-audit.test.ts | 138 ++++++++++ test/recovery-records.test.ts | 188 ++++++++++++++ test/reviewer-history.test.ts | 68 +++++ test/session-command.test.ts | 401 ++++++++++++++++++++++++++++- test/session-continuity.test.ts | 12 +- 19 files changed, 1438 insertions(+), 78 deletions(-) create mode 100644 src/lib/domain/reviewer-history.ts create mode 100644 test/memory-sync-audit.test.ts create mode 100644 test/recovery-records.test.ts create mode 100644 test/reviewer-history.test.ts diff --git a/.gitignore b/.gitignore index 30ff2f5..83f1070 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,14 @@ CLAUDE.md CLAUDE.local.md AI_REVIEW.local.md +# Internal AI / review artifacts should stay local-only +AGENTS.md +CHANGELOG.md +docs/progress-log.md +docs/review-guide.md +docs/reviewer-handoff.md +docs/next-phase-brief.md + # OS / editor noise .DS_Store *.log diff --git a/README.en.md b/README.en.md index a1e19f7..f96490a 100644 --- a/README.en.md +++ b/README.en.md @@ -179,7 +179,7 @@ cam audit # check the repository for unexpected sensitive content - `cam audit`: repository-level privacy and secret-hygiene audit. - `cam memory --recent [count]`: durable sync audit for recent `applied`, `no-op`, and `skipped` sync events, without mixing in manual `remember` / `forget`. -- `cam session save|load|status`: continuity audit surface for the latest diagnostics and latest audit drill-down; `load` / `status` text output additionally shows a compact recent preview, all three `--json` variants return recent audit entries, and a pending continuity recovery marker appears when continuity Markdown was written but audit persistence failed. +- `cam session save|load|status`: continuity audit surface for the latest diagnostics, latest rollout, and latest audit drill-down; `load` / `status` text output additionally shows a compact prior preview that excludes the latest entry and coalesces consecutive repeats, all three `--json` variants continue to return raw recent audit entries, and a pending continuity recovery marker appears when continuity Markdown was written but audit persistence failed. ## How it works @@ -252,9 +252,6 @@ See the architecture docs for the full storage and boundary breakdown. ### Maintainer and reviewer docs - [Session continuity design](docs/session-continuity.md) -- [Progress log](docs/progress-log.md) -- [Review guide](docs/review-guide.md) -- [Reviewer handoff](docs/reviewer-handoff.md) - [Release checklist](docs/release-checklist.md) - [Contributing](CONTRIBUTING.md) @@ -294,7 +291,6 @@ Current public-ready status: ## Contributing and license - Contribution guide: [CONTRIBUTING.md](./CONTRIBUTING.md) -- Changelog: [CHANGELOG.md](./CHANGELOG.md) - License: [Apache-2.0](./LICENSE) If you ever find a mismatch between the README, official docs, and local runtime observations, prefer: diff --git a/README.md b/README.md index b9cdc9d..282631d 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ cam audit # 检查仓库有没有意外的敏感内容 - `cam audit`: 仓库级的 privacy / secret hygiene 审计。 - `cam memory --recent [count]`: durable sync audit,查看 recent `applied` / `no-op` / `skipped` sync 事件,不混入 manual `remember` / `forget`。 -- `cam session save|load|status`: continuity audit surface,查看最新 continuity diagnostics 与 latest audit drill-down;其中 `load` / `status` 的文本输出会额外显示 compact recent preview,三个命令的 `--json` 都会返回 recent audit entries;当 continuity Markdown 已写入但 audit 失败时,还会暴露 pending continuity recovery marker。 +- `cam session save|load|status`: continuity audit surface,查看最新 continuity diagnostics、latest rollout 与 latest audit drill-down;其中 `load` / `status` 的文本输出会额外显示 compact prior preview(排除 latest,并收敛连续重复项),三个命令的 `--json` 都会继续返回 raw recent audit entries;当 continuity Markdown 已写入但 audit 失败时,还会暴露 pending continuity recovery marker。 ## 工作方式 @@ -252,9 +252,6 @@ Session continuity: ### 维护与审查文档 - [Session continuity 设计](docs/session-continuity.md) -- [Progress log](docs/progress-log.md) -- [Review guide](docs/review-guide.md) -- [Reviewer handoff](docs/reviewer-handoff.md) - [Release checklist](docs/release-checklist.md) - [Contributing](CONTRIBUTING.md) @@ -294,7 +291,6 @@ Session continuity: ## 贡献与许可 - 贡献指南:[CONTRIBUTING.md](./CONTRIBUTING.md) -- Changelog:[CHANGELOG.md](./CHANGELOG.md) - License:[Apache-2.0](./LICENSE) 如果你在 README、官方文档和本地运行时观察之间发现冲突,请优先相信: diff --git a/docs/README.en.md b/docs/README.en.md index 1e8b65a..4066423 100644 --- a/docs/README.en.md +++ b/docs/README.en.md @@ -18,15 +18,14 @@ 1. [Architecture](./architecture.en.md) 2. [Session continuity design](./session-continuity.md) -3. [Progress log](./progress-log.md) -4. [Next phase brief](./next-phase-brief.md) +3. [Release checklist](./release-checklist.md) +4. [ClaudeCode patch audit](./claudecode-patch-audit.md) ### Reviewers and external tools -1. [Review guide](./review-guide.md) -2. [Reviewer handoff](./reviewer-handoff.md) -3. [Release checklist](./release-checklist.md) -4. [ClaudeCode patch audit](./claudecode-patch-audit.md) +1. [Session continuity design](./session-continuity.md) +2. [Release checklist](./release-checklist.md) +3. [ClaudeCode patch audit](./claudecode-patch-audit.md) ## Core design docs @@ -41,18 +40,15 @@ | Document | Purpose | Current language | | :-- | :-- | :-- | | [Session continuity design](./session-continuity.md) | continuity boundaries, paths, and reviewer surfaces | English | -| [Progress log](./progress-log.md) | milestone history, current state, and known gaps | English | -| [Review guide](./review-guide.md) | what reviewers should read first and which risks matter most | English | -| [Reviewer handoff](./reviewer-handoff.md) | shortest complete handoff packet for AI tools and external review | English | | [Release checklist](./release-checklist.md) | release-time product, runtime, and docs checks | English | -| [Next phase brief](./next-phase-brief.md) | recommended next implementation window | English | +| [ClaudeCode patch audit](./claudecode-patch-audit.md) | historical patch-migration and comparison notes | English | ## Language policy - the default public landing page is the Chinese `README.md` - English readers can switch through [README.en.md](../README.en.md) or this page - the three core design docs are maintained in both Chinese and English -- reviewer and maintainer docs currently stay English-first to avoid internal drift +- supplementary maintainer docs currently stay English-first to avoid internal drift ## Documentation principles diff --git a/docs/README.md b/docs/README.md index 96e5574..ea75af4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,15 +18,14 @@ 1. [架构设计](./architecture.md) 2. [Session continuity 设计](./session-continuity.md) -3. [Progress log](./progress-log.md) -4. [Next phase brief](./next-phase-brief.md) +3. [Release checklist](./release-checklist.md) +4. [ClaudeCode patch audit](./claudecode-patch-audit.md) ### Reviewer / 外部审查工具 -1. [Review guide](./review-guide.md) -2. [Reviewer handoff](./reviewer-handoff.md) -3. [Release checklist](./release-checklist.md) -4. [ClaudeCode patch audit](./claudecode-patch-audit.md) +1. [Session continuity 设计](./session-continuity.md) +2. [Release checklist](./release-checklist.md) +3. [ClaudeCode patch audit](./claudecode-patch-audit.md) ## 核心设计文档 @@ -41,18 +40,15 @@ | 文档 | 作用 | 当前语言 | | :-- | :-- | :-- | | [Session continuity 设计](./session-continuity.md) | 临时 continuity layer 的边界、路径和 reviewer surface | English | -| [Progress log](./progress-log.md) | 阶段进度、里程碑与已知缺口 | English | -| [Review guide](./review-guide.md) | reviewer 应该从哪里开始看、看什么、关注什么风险 | English | -| [Reviewer handoff](./reviewer-handoff.md) | 外部工具或 AI 接手时的最短完整 handoff packet | English | | [Release checklist](./release-checklist.md) | 发布前的产品、运行时和文档核查清单 | English | -| [Next phase brief](./next-phase-brief.md) | 下一阶段推荐执行简报 | English | +| [ClaudeCode patch audit](./claudecode-patch-audit.md) | 历史 patch 迁移与对照记录 | English | ## 语言策略 - 默认公开首页使用中文 `README.md` - 英文访客可从 [README.en.md](../README.en.md) 或 [docs/README.en.md](./README.en.md) 进入英文入口 - 3 篇核心设计文档提供中英双版本 -- reviewer / maintainer 文档当前仍以英文为主,以减少内部维护漂移 +- 维护类补充文档当前仍以英文为主,以减少内部维护漂移 ## 文档设计原则 diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 55282b4..07da508 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -32,15 +32,10 @@ Use this checklist before cutting any alpha or beta release of `codex-auto-memor - `cam forget "..."` - `cam doctor` -## Review packet checks +## Documentation checks -- Update `CHANGELOG.md` with the new milestone and commit hash. -- Update `docs/progress-log.md` to reflect the current phase and remaining gaps. -- Update `docs/review-guide.md` if a new high-risk area or review order is introduced. -- Update `docs/reviewer-handoff.md` so external review tools can pick up the current state quickly. - Update the bilingual docs entry pages (`docs/README.md` and `docs/README.en.md`) if the public reading path changed. - Re-check the current official Codex and Claude public docs before changing migration wording; if the public posture is unchanged, say so explicitly in the handoff. -- Refresh the local ignored AI handoff file `AI_REVIEW.local.md` with current review/test instructions before handing off to another agent. - Ensure the latest milestone commit is focused enough to review independently. ## Native compatibility checks diff --git a/docs/session-continuity.md b/docs/session-continuity.md index 00cc140..3285f7b 100644 --- a/docs/session-continuity.md +++ b/docs/session-continuity.md @@ -158,10 +158,11 @@ cam session clear - project-local continuity - the effective merged resume brief - the latest continuity generation path and fallback status +- the latest rollout path - a small latest-generation drill-down for evidence counts and written continuity paths -- a compact recent generation preview sourced from the continuity audit log +- a compact prior-generation preview sourced from the continuity audit log that excludes the latest entry and coalesces consecutive repeats -`cam session status` now renders the latest generation path, the audit-log location, the same latest-generation drill-down, and the same compact recent generation preview without printing the full shared/local continuity bodies. +`cam session status` now renders the latest generation path, the latest rollout path, the audit-log location, the same latest-generation drill-down, and the same compact prior-generation preview without printing the full shared/local continuity bodies. Automatic injection and automatic saving are disabled by default. @@ -191,8 +192,8 @@ Reason: - reviewer/debug data belongs in an audit surface, not in the working-state note itself - the latest audit entry now remains exposed explicitly as `latestContinuityAuditEntry` through `cam session save --json`, `cam session load --json`, and `cam session status --json` - the compatibility summary field `latestContinuityDiagnostics` still exposes the latest path/fallback view for existing consumers -- the same commands now also expose recent audit entries so reviewers can verify short history without opening the JSONL directly -- the default text surfaces now show the latest evidence counts and written paths without becoming a dedicated history browser +- the same commands now also expose raw recent audit entries so reviewers can verify short history without opening the JSONL directly +- the default `load` / `status` text surfaces now show the latest rollout, the latest evidence counts and written paths, plus a compact prior preview without becoming a dedicated history browser ## Startup behavior diff --git a/src/lib/commands/audit.ts b/src/lib/commands/audit.ts index e0d7bd7..d09c092 100644 --- a/src/lib/commands/audit.ts +++ b/src/lib/commands/audit.ts @@ -8,6 +8,18 @@ interface AuditCommandOptions { noHistory?: boolean; } +function resolveIncludeHistory(options: AuditCommandOptions): boolean { + if (typeof options.history === "boolean") { + return options.history; + } + + if (options.noHistory === true) { + return false; + } + + return true; +} + function formatFinding(finding: AuditFinding): string[] { return [ `- [${finding.severity}] ${finding.classification} ${finding.location}`, @@ -28,10 +40,10 @@ function formatSummary(report: AuditReport): string[] { } export async function runAudit(options: AuditCommandOptions = {}): Promise { - const includeHistory = options.noHistory ? false : true; + const includeHistory = resolveIncludeHistory(options); const report = await runAuditScan({ cwd: options.cwd ?? process.cwd(), - includeHistory: options.history ? true : includeHistory + includeHistory }); if (options.json) { @@ -42,7 +54,7 @@ export async function runAudit(options: AuditCommandOptions = {}): Promise [ + ...formatMemorySyncAuditEntry(group.latest), + ...(group.rawCount > 1 ? [` Repeated similar sync events hidden: ${group.rawCount - 1}`] : []) + ]); + + if (preview.omittedRawCount > 0) { + lines.push(`- older sync events omitted: ${preview.omittedRawCount}`); + } + + return { + lines, + groupCount: preview.groups.length + }; +} + export async function runMemory(options: MemoryOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const configScope = options.configScope ?? "local"; @@ -66,9 +114,10 @@ export async function runMemory(options: MemoryOptions = {}): Promise { topics: await runtime.syncService.memoryStore.listTopics(scope) })) ); - const recentSyncAudit = options.recent - ? await runtime.syncService.memoryStore.readRecentSyncAuditEntries(recentCount) + const recentSyncAuditPreviewEntries = options.recent + ? await runtime.syncService.memoryStore.readRecentSyncAuditEntries(recentCount * 2) : []; + const recentSyncAudit = recentSyncAuditPreviewEntries.slice(0, recentCount); const pendingSyncRecovery = await runtime.syncService.memoryStore.readSyncRecoveryRecord(); const startupFilesByScope = { global: startup.sourceFiles.filter( @@ -194,11 +243,13 @@ export async function runMemory(options: MemoryOptions = {}): Promise { } } - if (recentSyncAudit.length > 0) { - lines.push("", `Recent sync events (${recentSyncAudit.length}):`); - for (const item of recentSyncAudit) { - lines.push(...formatMemorySyncAuditEntry(item)); - } + if (recentSyncAuditPreviewEntries.length > 0) { + const compactRecentSyncAudit = formatRecentSyncAuditLines( + recentSyncAuditPreviewEntries, + recentCount + ); + lines.push("", `Recent sync events (${compactRecentSyncAudit.groupCount} grouped):`); + lines.push(...compactRecentSyncAudit.lines); } if (pendingSyncRecovery) { diff --git a/src/lib/commands/session.ts b/src/lib/commands/session.ts index 04e1539..43a32db 100644 --- a/src/lib/commands/session.ts +++ b/src/lib/commands/session.ts @@ -10,6 +10,7 @@ import { buildContinuityRecoveryRecord, matchesContinuityRecoveryRecord } from "../domain/recovery-records.js"; +import { buildCompactHistoryPreview } from "../domain/reviewer-history.js"; import { compileSessionContinuity, createEmptySessionContinuityState @@ -33,7 +34,8 @@ interface SessionOptions { } const recentContinuityAuditLimit = 5; -const recentContinuityPreviewLimit = 3; +const recentContinuityPreviewReadLimit = 10; +const recentContinuityPreviewGroupLimit = 3; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -55,10 +57,46 @@ function formatRecentGenerationLines(entries: SessionContinuityAuditEntry[]): st return ["- none recorded yet"]; } - return entries.slice(0, recentContinuityPreviewLimit).flatMap((entry) => [ - `- ${entry.generatedAt}: ${formatSessionContinuityDiagnostics(entry)}`, - ` Rollout: ${entry.rolloutPath}` + const preview = buildCompactHistoryPreview(entries, { + excludeLeadingCount: 1, + maxGroups: recentContinuityPreviewGroupLimit, + getSignature: (entry) => + JSON.stringify({ + rolloutPath: entry.rolloutPath, + sourceSessionId: entry.sourceSessionId, + scope: entry.scope, + preferredPath: entry.preferredPath, + actualPath: entry.actualPath, + fallbackReason: entry.fallbackReason ?? null, + codexExitCode: entry.codexExitCode ?? null, + evidenceCounts: { + successfulCommands: entry.evidenceCounts.successfulCommands, + failedCommands: entry.evidenceCounts.failedCommands, + fileWrites: entry.evidenceCounts.fileWrites, + nextSteps: entry.evidenceCounts.nextSteps, + untried: entry.evidenceCounts.untried + }, + writtenPaths: entry.writtenPaths + }) + }); + + if (preview.totalRawCount === 0) { + return ["- none beyond latest"]; + } + + const lines = preview.groups.flatMap((group) => [ + `- ${group.latest.generatedAt}: ${formatSessionContinuityDiagnostics(group.latest)}`, + ` Rollout: ${group.latest.rolloutPath}`, + ...(group.rawCount > 1 + ? [` Repeated similar generations hidden: ${group.rawCount - 1}`] + : []) ]); + + if (preview.omittedRawCount > 0) { + lines.push(`- older generations omitted: ${preview.omittedRawCount}`); + } + + return lines.length > 0 ? lines : ["- none beyond latest"]; } function formatPendingContinuityRecovery( @@ -136,10 +174,15 @@ export async function runSession( ); const excludePath = scope === "project" ? null : runtime.sessionContinuityStore.getLocalIgnorePath(); - const recentContinuityAuditEntries = await runtime.sessionContinuityStore.readRecentAuditEntries( + const recentContinuityAuditPreviewEntries = + await runtime.sessionContinuityStore.readRecentAuditEntries( + recentContinuityPreviewReadLimit + ); + const recentContinuityAuditEntries = recentContinuityAuditPreviewEntries.slice( + 0, recentContinuityAuditLimit ); - const latestContinuityAuditEntry = recentContinuityAuditEntries[0] ?? null; + const latestContinuityAuditEntry = recentContinuityAuditPreviewEntries[0] ?? null; const pendingContinuityRecovery = await runtime.sessionContinuityStore.readRecoveryRecord(); if (options.json) { @@ -197,10 +240,13 @@ export async function runSession( const localLocation = await runtime.sessionContinuityStore.getLocation("project-local"); const projectState = await runtime.sessionContinuityStore.readState("project"); const localState = await runtime.sessionContinuityStore.readState("project-local"); - const recentContinuityAuditEntries = await runtime.sessionContinuityStore.readRecentAuditEntries( + const recentContinuityAuditPreviewEntries = + await runtime.sessionContinuityStore.readRecentAuditEntries(recentContinuityPreviewReadLimit); + const recentContinuityAuditEntries = recentContinuityAuditPreviewEntries.slice( + 0, recentContinuityAuditLimit ); - const latestContinuityAuditEntry = recentContinuityAuditEntries[0] ?? null; + const latestContinuityAuditEntry = recentContinuityAuditPreviewEntries[0] ?? null; const pendingContinuityRecovery = await runtime.sessionContinuityStore.readRecoveryRecord(); const latestContinuityDiagnostics = latestContinuityAuditEntry ? toSessionContinuityDiagnostics(latestContinuityAuditEntry) @@ -245,6 +291,7 @@ export async function runSession( `Project continuity: ${projectLocation.exists ? "active" : "missing"} (${projectLocation.path})`, `Project-local continuity: ${localLocation.exists ? "active" : "missing"} (${localLocation.path})`, `Latest generation: ${latestContinuityDiagnostics ? formatSessionContinuityDiagnostics(latestContinuityDiagnostics) : "none recorded yet"}`, + ...(latestContinuityAuditEntry ? [`Latest rollout: ${latestContinuityAuditEntry.rolloutPath}`] : []), `Continuity audit: ${runtime.sessionContinuityStore.paths.auditFile}`, ...(latestContinuityAuditEntry ? formatSessionContinuityAuditDrillDown(latestContinuityAuditEntry) @@ -255,8 +302,8 @@ export async function runSession( runtime.sessionContinuityStore.getRecoveryPath() ) : []), - "Recent generations:", - ...formatRecentGenerationLines(recentContinuityAuditEntries), + "Recent prior generations:", + ...formatRecentGenerationLines(recentContinuityAuditPreviewEntries), "", "Shared project continuity:", `Goal: ${projectState?.goal || "No active goal recorded."}`, @@ -375,6 +422,7 @@ export async function runSession( `Shared continuity: ${projectLocation.exists ? "active" : "missing"} (${projectLocation.path})`, `Project-local continuity: ${localLocation.exists ? "active" : "missing"} (${localLocation.path})`, `Latest generation: ${latestContinuityDiagnostics ? formatSessionContinuityDiagnostics(latestContinuityDiagnostics) : "none recorded yet"}`, + ...(latestContinuityAuditEntry ? [`Latest rollout: ${latestContinuityAuditEntry.rolloutPath}`] : []), `Continuity audit: ${runtime.sessionContinuityStore.paths.auditFile}`, ...(latestContinuityAuditEntry ? formatSessionContinuityAuditDrillDown(latestContinuityAuditEntry) @@ -385,8 +433,8 @@ export async function runSession( runtime.sessionContinuityStore.getRecoveryPath() ) : []), - "Recent generations:", - ...formatRecentGenerationLines(recentContinuityAuditEntries), + "Recent prior generations:", + ...formatRecentGenerationLines(recentContinuityAuditPreviewEntries), "", `Shared updated at: ${projectState?.updatedAt ?? "n/a"}`, `Project-local updated at: ${localState?.updatedAt ?? "n/a"}`, diff --git a/src/lib/domain/reviewer-history.ts b/src/lib/domain/reviewer-history.ts new file mode 100644 index 0000000..6b4f7bc --- /dev/null +++ b/src/lib/domain/reviewer-history.ts @@ -0,0 +1,50 @@ +export interface CompactHistoryGroup { + latest: T; + rawCount: number; +} + +export interface CompactHistoryPreview { + groups: CompactHistoryGroup[]; + omittedRawCount: number; + totalRawCount: number; +} + +interface BuildCompactHistoryPreviewOptions { + getSignature: (entry: T) => string; + maxGroups?: number; + excludeLeadingCount?: number; +} + +export function buildCompactHistoryPreview( + entries: T[], + options: BuildCompactHistoryPreviewOptions +): CompactHistoryPreview { + const eligibleEntries = entries.slice(options.excludeLeadingCount ?? 0); + const grouped: Array & { signature: string }> = []; + + for (const entry of eligibleEntries) { + const signature = options.getSignature(entry); + const previous = grouped.at(-1); + if (previous?.signature === signature) { + previous.rawCount += 1; + continue; + } + + grouped.push({ + latest: entry, + rawCount: 1, + signature + }); + } + + const limitedGroups = grouped + .slice(0, options.maxGroups ?? grouped.length) + .map(({ latest, rawCount }) => ({ latest, rawCount })); + const shownRawCount = limitedGroups.reduce((sum, group) => sum + group.rawCount, 0); + + return { + groups: limitedGroups, + omittedRawCount: Math.max(0, eligibleEntries.length - shownRawCount), + totalRawCount: eligibleEntries.length + }; +} diff --git a/src/lib/security/audit.ts b/src/lib/security/audit.ts index e6da091..a4daecc 100644 --- a/src/lib/security/audit.ts +++ b/src/lib/security/audit.ts @@ -9,7 +9,7 @@ import type { } from "../types.js"; import { runCommandCapture } from "../util/process.js"; import { trimText } from "../util/text.js"; -import { buildAuditRules, classifyAuditMatch } from "./patterns.js"; +import { buildAuditRules, classifyAuditMatch, type AuditRule } from "./patterns.js"; const auditRules = buildAuditRules(); @@ -25,6 +25,7 @@ const classificationOrder: AuditClassification[] = [ "synthetic-test-fixture", "generic-local-path" ]; +const historyRevisionBatchSize = 200; function createEmptySeveritySummary(): Record { return { @@ -148,6 +149,50 @@ async function scanWorkingTree(cwd: string): Promise { } async function scanHistory(cwd: string): Promise { + const commits = await listCommits(cwd); + if (commits.length === 0) { + return []; + } + + const grepFindings = scanHistoryWithGitGrep(cwd, commits); + if (grepFindings) { + return grepFindings; + } + + return scanHistoryLegacy(cwd); +} + +function scanHistoryWithGitGrep(cwd: string, commits: string[]): AuditFinding[] | null { + const findings: AuditFinding[] = []; + + for (const revisions of chunkArray(commits, historyRevisionBatchSize)) { + for (const rule of auditRules) { + const result = runCommandCapture("git", buildHistoryGrepArgs(rule, revisions), cwd); + if (result.exitCode === 1) { + continue; + } + if (result.exitCode !== 0) { + return null; + } + + for (const rawLine of result.stdout.split("\n")) { + const line = rawLine.trimEnd(); + if (!line) { + continue; + } + + const parsed = parseHistoryGrepLine(line, rule); + if (parsed) { + findings.push(parsed); + } + } + } + } + + return findings; +} + +async function scanHistoryLegacy(cwd: string): Promise { const commits = await listCommits(cwd); const findings: AuditFinding[] = []; for (const revision of commits) { @@ -168,6 +213,57 @@ async function scanHistory(cwd: string): Promise { return findings; } +function buildHistoryGrepArgs(rule: AuditRule, revisions: string[]): string[] { + return [ + "grep", + "-nI", + "-z", + "-P", + ...(rule.regex.flags.includes("i") ? ["-i"] : []), + rule.regex.source, + ...revisions, + "--" + ]; +} + +function parseHistoryGrepLine(line: string, rule: AuditRule): AuditFinding | null { + const [filePath, lineNumberRaw, ...textParts] = line.split("\u0000"); + if (!filePath || !lineNumberRaw || textParts.length === 0) { + return null; + } + + const lineNumber = Number.parseInt(lineNumberRaw, 10); + if (!Number.isFinite(lineNumber)) { + return null; + } + + const lineText = textParts.join("\u0000"); + if (!filePath.includes(":")) { + return null; + } + + const classified = classifyAuditMatch(filePath, lineText, rule); + return makeFinding( + filePath, + lineNumber, + lineText, + rule.id, + rule.summary, + "git-history", + classified.classification, + classified.severity, + classified.recommendation + ); +} + +function chunkArray(items: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +} + function dedupeFindings(findings: AuditFinding[]): AuditFinding[] { const byKey = new Map(); for (const finding of findings) { diff --git a/test/audit.test.ts b/test/audit.test.ts index 8cafb37..e6d6a1b 100644 --- a/test/audit.test.ts +++ b/test/audit.test.ts @@ -1,12 +1,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { runAuditScan } from "../src/lib/security/audit.js"; import { runAudit } from "../src/lib/commands/audit.js"; import { runCommandCapture } from "../src/lib/util/process.js"; +import * as processUtils from "../src/lib/util/process.js"; const tempDirs: string[] = []; +const sourceCliPath = path.resolve("src/cli.ts"); +const tsxBinaryPath = path.resolve( + process.platform === "win32" ? "node_modules/.bin/tsx.cmd" : "node_modules/.bin/tsx" +); async function tempDir(prefix: string): Promise { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); @@ -28,6 +33,10 @@ async function initRepo(repoDir: string): Promise { runCommandCapture("git", ["commit", "-m", "init"], repoDir, gitEnv); } +function runCli(repoDir: string, args: string[]) { + return runCommandCapture(tsxBinaryPath, [sourceCliPath, ...args], repoDir); +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -86,7 +95,7 @@ describe("audit scan", () => { expect(report.findings.some((finding) => finding.ruleId === "absolute-user-path")).toBe(false); }, 30_000); - it("supports no-history mode from the command surface", async () => { + it("supports no-history mode from the direct command helper", async () => { const repoDir = await tempDir("cam-audit-no-history-"); await initRepo(repoDir); await fs.writeFile( @@ -113,9 +122,175 @@ describe("audit scan", () => { cwd: repoDir, noHistory: true }); + const explicitHistoryFalseOutput = await runAudit({ + cwd: repoDir, + history: false + }); expect(output).toContain("History scan: disabled"); expect(output).toContain("generic-local-path"); + expect(explicitHistoryFalseOutput).toContain("History scan: disabled"); + }); + + it("respects --no-history on the real CLI surface and still supports --history", async () => { + const repoDir = await tempDir("cam-audit-cli-history-"); + await initRepo(repoDir); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Codex Auto Memory", + GIT_AUTHOR_EMAIL: "cam@example.com", + GIT_COMMITTER_NAME: "Codex Auto Memory", + GIT_COMMITTER_EMAIL: "cam@example.com" + }; + + const historicalLocalPath = ["/Users", "alice/project/"].join("/"); + await fs.writeFile( + path.join(repoDir, "README.md"), + `Historical local path: ${historicalLocalPath}.\n`, + "utf8" + ); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "history-only-path"], repoDir, gitEnv); + + await fs.writeFile(path.join(repoDir, "README.md"), "Safe current contents.\n", "utf8"); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "cleanup"], repoDir, gitEnv); + + const noHistoryResult = runCli(repoDir, ["audit", "--json", "--no-history"]); + const historyResult = runCli(repoDir, ["audit", "--json", "--history"]); + const defaultResult = runCli(repoDir, ["audit", "--json"]); + + expect(noHistoryResult.exitCode).toBe(0); + expect(historyResult.exitCode).toBe(0); + expect(defaultResult.exitCode).toBe(0); + + const noHistoryJson = JSON.parse(noHistoryResult.stdout) as { + findings: Array<{ sourceType: string; location: string }>; + summary: { bySeverity: { medium: number } }; + }; + const historyJson = JSON.parse(historyResult.stdout) as { + findings: Array<{ sourceType: string; location: string; classification: string; severity: string }>; + summary: { bySeverity: { medium: number } }; + }; + const defaultJson = JSON.parse(defaultResult.stdout) as { + findings: Array<{ sourceType: string; location: string }>; + summary: { bySeverity: { medium: number } }; + }; + + expect(noHistoryJson.findings.some((finding) => finding.sourceType === "git-history")).toBe(false); + expect(noHistoryJson.summary.bySeverity.medium).toBe(0); + expect(historyJson.findings.some((finding) => finding.sourceType === "git-history")).toBe(true); + expect( + historyJson.findings.some( + (finding) => + finding.sourceType === "git-history" && + finding.classification === "manual-review-needed" && + finding.severity === "medium" && + finding.location.includes("README.md") + ) + ).toBe(true); + expect(historyJson.summary.bySeverity.medium).toBeGreaterThan(0); + expect(defaultJson.findings.some((finding) => finding.sourceType === "git-history")).toBe(true); + expect(defaultJson.summary.bySeverity.medium).toBeGreaterThan(0); + }); + + it("keeps the correct history line number when matched text contains colon-digit-colon", async () => { + const repoDir = await tempDir("cam-audit-history-parse-"); + await initRepo(repoDir); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Codex Auto Memory", + GIT_AUTHOR_EMAIL: "cam@example.com", + GIT_COMMITTER_NAME: "Codex Auto Memory", + GIT_COMMITTER_EMAIL: "cam@example.com" + }; + + const historicalLocalPath = ["/Users", "alice/project/"].join("/"); + const historicalLine = [ + "Historical local path: ", + historicalLocalPath, + " with port:3000: note." + ].join(""); + await fs.writeFile(path.join(repoDir, "README.md"), `${historicalLine}\n`, "utf8"); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "history-parse"], repoDir, gitEnv); + + await fs.writeFile(path.join(repoDir, "README.md"), "Safe current contents.\n", "utf8"); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "cleanup"], repoDir, gitEnv); + + const report = await runAuditScan({ + cwd: repoDir, + includeHistory: true + }); + const historyPathFinding = report.findings.find( + (finding) => + finding.sourceType === "git-history" && + finding.ruleId === "absolute-user-path" && + finding.location.endsWith("README.md:1") + ); + + expect(historyPathFinding).toBeDefined(); + expect(historyPathFinding?.snippet).toContain("port:3000: note."); + }); + + it("falls back to the legacy history walk when git grep fails", async () => { + const repoDir = await tempDir("cam-audit-history-fallback-"); + await initRepo(repoDir); + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Codex Auto Memory", + GIT_AUTHOR_EMAIL: "cam@example.com", + GIT_COMMITTER_NAME: "Codex Auto Memory", + GIT_COMMITTER_EMAIL: "cam@example.com" + }; + + const historicalLocalPath = ["/Users", "alice/project/"].join("/"); + await fs.writeFile( + path.join(repoDir, "README.md"), + `Historical local path: ${historicalLocalPath}.\n`, + "utf8" + ); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "history-only-path"], repoDir, gitEnv); + + await fs.writeFile(path.join(repoDir, "README.md"), "Safe current contents.\n", "utf8"); + runCommandCapture("git", ["add", "README.md"], repoDir, gitEnv); + runCommandCapture("git", ["commit", "-m", "cleanup"], repoDir, gitEnv); + + const originalRunCommandCapture = processUtils.runCommandCapture; + const grepSpy = vi + .spyOn(processUtils, "runCommandCapture") + .mockImplementation((command, args, cwd, env, input) => { + if (command === "git" && args[0] === "grep") { + return { + stdout: "", + stderr: "simulated git grep failure", + exitCode: 2 + }; + } + + return originalRunCommandCapture(command, args, cwd, env, input); + }); + + try { + const report = await runAuditScan({ + cwd: repoDir, + includeHistory: true + }); + + expect(report.findings.some((finding) => finding.sourceType === "git-history")).toBe(true); + expect( + report.findings.some( + (finding) => + finding.sourceType === "git-history" && + finding.ruleId === "absolute-user-path" && + finding.location.endsWith("README.md:1") + ) + ).toBe(true); + } finally { + grepSpy.mockRestore(); + } }); it("returns stable severity counts from the json command surface", async () => { diff --git a/test/memory-command.test.ts b/test/memory-command.test.ts index 0723566..2e6ba0e 100644 --- a/test/memory-command.test.ts +++ b/test/memory-command.test.ts @@ -276,6 +276,159 @@ describe("runMemory", () => { actualExtractorMode: "heuristic" }); expect(output.recentAudit).toEqual(output.recentSyncAudit); + + const textOutput = await runMemory({ + cwd: projectDir, + recent: "3" + }); + expect(textOutput).toContain("Recent sync events (1 grouped):"); + }); + + it("keeps json recent sync audit raw while compacting repeated sync events in text output", async () => { + const homeDir = await tempDir("cam-memory-compact-home-"); + const projectDir = await tempDir("cam-memory-compact-project-"); + const memoryRoot = await tempDir("cam-memory-compact-root-"); + process.env.HOME = homeDir; + + const projectConfig: AppConfig = { + autoMemoryEnabled: true, + extractorMode: "heuristic", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }; + await fs.writeFile( + path.join(projectDir, "codex-auto-memory.json"), + JSON.stringify(projectConfig), + "utf8" + ); + await fs.writeFile( + path.join(projectDir, ".codex-auto-memory.local.json"), + JSON.stringify({ + autoMemoryDirectory: memoryRoot + }), + "utf8" + ); + + const project = detectProjectContext(projectDir); + const store = new MemoryStore(project, { + ...projectConfig, + autoMemoryDirectory: memoryRoot + }); + await store.ensureLayout(); + await store.appendSyncAuditEntry({ + appliedAt: "2026-03-14T12:00:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-oldest.jsonl", + sessionId: "session-oldest", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "no-op", + appliedCount: 0, + scopesTouched: [], + resultSummary: "0 operations applied", + operations: [] + }); + await store.appendSyncAuditEntry({ + appliedAt: "2026-03-14T12:01:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-repeat.jsonl", + sessionId: "session-repeat", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "skipped", + skipReason: "already-processed", + appliedCount: 0, + scopesTouched: [], + resultSummary: "Skipped rollout; it was already processed", + operations: [] + }); + await store.appendSyncAuditEntry({ + appliedAt: "2026-03-14T12:02:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-repeat.jsonl", + sessionId: "session-repeat", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "skipped", + skipReason: "already-processed", + appliedCount: 0, + scopesTouched: [], + resultSummary: "Skipped rollout; it was already processed", + operations: [] + }); + await store.appendSyncAuditEntry({ + appliedAt: "2026-03-14T12:03:00.000Z", + projectId: project.projectId, + worktreeId: project.worktreeId, + rolloutPath: "/tmp/rollout-latest.jsonl", + sessionId: "session-latest", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "applied", + appliedCount: 1, + scopesTouched: ["project"], + resultSummary: "1 operation(s) applied", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "latest", + summary: "Prefer pnpm in this repository.", + details: ["Use pnpm instead of npm in this repository."], + reason: "Manual note.", + sources: ["manual"] + } + ] + }); + + const jsonOutput = JSON.parse( + await runMemory({ + cwd: projectDir, + json: true, + recent: "2" + }) + ) as MemoryCommandOutput; + const textOutput = await runMemory({ + cwd: projectDir, + recent: "2" + }); + + expect(jsonOutput.recentSyncAudit).toHaveLength(2); + expect(jsonOutput.recentSyncAudit[0]?.rolloutPath).toBe("/tmp/rollout-latest.jsonl"); + expect(jsonOutput.recentSyncAudit[1]?.rolloutPath).toBe("/tmp/rollout-repeat.jsonl"); + expect(textOutput).toContain("Recent sync events (2 grouped):"); + expect(textOutput).toContain("Repeated similar sync events hidden: 1"); + expect(textOutput).toContain("- older sync events omitted: 1"); + expect(textOutput.match(/\/tmp\/rollout-repeat\.jsonl/g) ?? []).toHaveLength(1); }); it("does not report startup-loaded files when the startup budget cannot fit quoted lines", async () => { diff --git a/test/memory-sync-audit.test.ts b/test/memory-sync-audit.test.ts new file mode 100644 index 0000000..a92248e --- /dev/null +++ b/test/memory-sync-audit.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + buildMemorySyncAuditEntry, + formatMemorySyncAuditEntry, + parseMemorySyncAuditEntry +} from "../src/lib/domain/memory-sync-audit.js"; + +describe("memory-sync-audit", () => { + it("parses legacy extractor fields into configured and actual values", () => { + const parsed = parseMemorySyncAuditEntry({ + appliedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "no-op", + appliedCount: 0, + scopesTouched: [], + resultSummary: "0 operations applied", + operations: [] + }); + + expect(parsed).toMatchObject({ + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic" + }); + }); + + it("builds applied entries without skipReason and dedupes touched scopes", () => { + const entry = buildMemorySyncAuditEntry({ + project: { + cwd: "/tmp/project", + projectRoot: "/tmp/project", + projectId: "project-1", + worktreeId: "worktree-1" + }, + config: { + autoMemoryEnabled: true, + extractorMode: "codex", + defaultScope: "project", + maxStartupLines: 200, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false, + sessionContinuityLocalPathStyle: "codex", + maxSessionContinuityLines: 60, + codexBinary: "codex" + }, + rolloutPath: "/tmp/rollout.jsonl", + configuredExtractorName: "codex-ephemeral", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "applied", + operations: [ + { + action: "upsert", + scope: "project", + topic: "workflow", + id: "first" + }, + { + action: "delete", + scope: "project", + topic: "workflow", + id: "second" + } + ] + }); + + expect(entry.skipReason).toBeUndefined(); + expect(entry.appliedCount).toBe(2); + expect(entry.scopesTouched).toEqual(["project"]); + expect(entry.resultSummary).toBe("2 operation(s) applied"); + }); + + it("formats reviewer text with recovery badge, unknown session, and configured extractor diff", () => { + const lines = formatMemorySyncAuditEntry({ + appliedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + configuredExtractorMode: "codex", + configuredExtractorName: "codex-ephemeral", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "skipped", + skipReason: "already-processed", + isRecovery: true, + appliedCount: 0, + scopesTouched: [], + resultSummary: "Skipped rollout; it was already processed", + operations: [] + }); + + expect(lines[0]).toContain("[skipped] [recovery]"); + expect(lines[1]).toContain("Session: unknown"); + expect(lines[2]).toContain("Applied: 0 | Scopes: none"); + expect(lines).toContain( + " Configured: codex-ephemeral (codex) -> Actual: heuristic (heuristic)" + ); + expect(lines).toContain(" Skip reason: already-processed"); + expect(lines).toContain(" Rollout: /tmp/rollout.jsonl"); + }); + + it("omits configured extractor line when configured and actual match", () => { + const lines = formatMemorySyncAuditEntry({ + appliedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-1", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + extractorMode: "heuristic", + extractorName: "heuristic", + sessionSource: "rollout-jsonl", + status: "no-op", + appliedCount: 0, + scopesTouched: [], + resultSummary: "0 operations applied", + operations: [] + }); + + expect(lines.some((line) => line.includes("Configured:"))).toBe(false); + expect(lines.some((line) => line.includes("Skip reason:"))).toBe(false); + }); +}); diff --git a/test/recovery-records.test.ts b/test/recovery-records.test.ts new file mode 100644 index 0000000..a28f7f1 --- /dev/null +++ b/test/recovery-records.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from "vitest"; +import { + buildContinuityRecoveryRecord, + buildSyncRecoveryRecord, + isContinuityRecoveryRecord, + isSyncRecoveryRecord, + matchesContinuityRecoveryRecord, + matchesSyncRecoveryRecord +} from "../src/lib/domain/recovery-records.js"; + +describe("recovery-records", () => { + it("accepts a valid sync recovery record built by the helper", () => { + const record = buildSyncRecoveryRecord({ + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-1", + configuredExtractorMode: "codex", + configuredExtractorName: "codex-ephemeral", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + status: "applied", + appliedCount: 2, + scopesTouched: ["project", "project-local"], + failedStage: "processed-state-write", + failureMessage: "state write failed", + auditEntryWritten: true + }); + + expect(isSyncRecoveryRecord(record)).toBe(true); + }); + + it("rejects an invalid sync recovery record shape", () => { + expect( + isSyncRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + configuredExtractorMode: "codex", + configuredExtractorName: "codex-ephemeral", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + status: "skipped", + appliedCount: 1, + scopesTouched: ["project"], + failedStage: "audit-write", + failureMessage: "bad status", + auditEntryWritten: false + }) + ).toBe(false); + }); + + it("matches sync recovery records by logical identity fields only", () => { + const record = buildSyncRecoveryRecord({ + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-1", + configuredExtractorMode: "heuristic", + configuredExtractorName: "heuristic", + actualExtractorMode: "heuristic", + actualExtractorName: "heuristic", + status: "no-op", + appliedCount: 0, + scopesTouched: [], + failedStage: "audit-write", + failureMessage: "audit failed", + auditEntryWritten: false + }); + + expect( + matchesSyncRecoveryRecord(record, { + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-1" + }) + ).toBe(true); + expect( + matchesSyncRecoveryRecord(record, { + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sessionId: "session-2" + }) + ).toBe(false); + }); + + it("accepts a valid continuity recovery record with scope both", () => { + const record = buildContinuityRecoveryRecord({ + projectId: "project-1", + worktreeId: "worktree-1", + diagnostics: { + generatedAt: "2026-03-18T00:00:00.000Z", + rolloutPath: "/tmp/rollout.jsonl", + sourceSessionId: "session-1", + preferredPath: "codex", + actualPath: "heuristic", + fallbackReason: "low-signal", + codexExitCode: 17, + evidenceCounts: { + successfulCommands: 1, + failedCommands: 2, + fileWrites: 3, + nextSteps: 4, + untried: 5 + } + }, + scope: "both", + writtenPaths: ["/tmp/shared.md", "/tmp/local.md"], + failedStage: "audit-write", + failureMessage: "audit append failed" + }); + + expect(isContinuityRecoveryRecord(record)).toBe(true); + }); + + it("rejects an invalid continuity recovery record shape", () => { + expect( + isContinuityRecoveryRecord({ + recordedAt: "2026-03-18T00:00:00.000Z", + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sourceSessionId: "session-1", + scope: "invalid", + writtenPaths: ["/tmp/shared.md"], + preferredPath: "heuristic", + actualPath: "heuristic", + evidenceCounts: { + successfulCommands: 1, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + failedStage: "audit-write", + failureMessage: "bad scope" + }) + ).toBe(false); + }); + + it("matches continuity recovery records by full logical identity including scope", () => { + const record = buildContinuityRecoveryRecord({ + projectId: "project-1", + worktreeId: "worktree-1", + diagnostics: { + generatedAt: "2026-03-18T00:00:00.000Z", + rolloutPath: "/tmp/rollout.jsonl", + sourceSessionId: "session-1", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 1, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + } + }, + scope: "both", + writtenPaths: ["/tmp/shared.md", "/tmp/local.md"], + failedStage: "audit-write", + failureMessage: "audit append failed" + }); + + expect( + matchesContinuityRecoveryRecord(record, { + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sourceSessionId: "session-1", + scope: "both" + }) + ).toBe(true); + expect( + matchesContinuityRecoveryRecord(record, { + projectId: "project-1", + worktreeId: "worktree-1", + rolloutPath: "/tmp/rollout.jsonl", + sourceSessionId: "session-1", + scope: "project" + }) + ).toBe(false); + }); +}); diff --git a/test/reviewer-history.test.ts b/test/reviewer-history.test.ts new file mode 100644 index 0000000..c0795e9 --- /dev/null +++ b/test/reviewer-history.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { buildCompactHistoryPreview } from "../src/lib/domain/reviewer-history.js"; + +describe("buildCompactHistoryPreview", () => { + it("coalesces only consecutive entries with the same signature", () => { + const preview = buildCompactHistoryPreview( + [ + { id: "a-1", signature: "a" }, + { id: "a-2", signature: "a" }, + { id: "b-1", signature: "b" }, + { id: "a-3", signature: "a" } + ], + { + getSignature: (entry) => entry.signature + } + ); + + expect(preview.groups).toEqual([ + { latest: { id: "a-1", signature: "a" }, rawCount: 2 }, + { latest: { id: "b-1", signature: "b" }, rawCount: 1 }, + { latest: { id: "a-3", signature: "a" }, rawCount: 1 } + ]); + expect(preview.omittedRawCount).toBe(0); + expect(preview.totalRawCount).toBe(4); + }); + + it("can exclude the latest entry before building compact preview groups", () => { + const preview = buildCompactHistoryPreview( + [ + { id: "latest", signature: "latest" }, + { id: "older-1", signature: "older" }, + { id: "older-2", signature: "older" } + ], + { + excludeLeadingCount: 1, + getSignature: (entry) => entry.signature + } + ); + + expect(preview.groups).toEqual([ + { latest: { id: "older-1", signature: "older" }, rawCount: 2 } + ]); + expect(preview.omittedRawCount).toBe(0); + expect(preview.totalRawCount).toBe(2); + }); + + it("tracks omitted raw entries after group limiting", () => { + const preview = buildCompactHistoryPreview( + [ + { id: "a-1", signature: "a" }, + { id: "a-2", signature: "a" }, + { id: "b-1", signature: "b" }, + { id: "c-1", signature: "c" } + ], + { + maxGroups: 2, + getSignature: (entry) => entry.signature + } + ); + + expect(preview.groups).toEqual([ + { latest: { id: "a-1", signature: "a" }, rawCount: 2 }, + { latest: { id: "b-1", signature: "b" }, rawCount: 1 } + ]); + expect(preview.omittedRawCount).toBe(1); + expect(preview.totalRawCount).toBe(4); + }); +}); diff --git a/test/session-command.test.ts b/test/session-command.test.ts index 0f3adb3..c6cdc09 100644 --- a/test/session-command.test.ts +++ b/test/session-command.test.ts @@ -110,12 +110,74 @@ function rolloutFixture(projectDir: string, message: string): string { ].join("\n"); } +async function writeWrapperMockCodex( + repoDir: string, + sessionsDir: string, + options: { + sessionId: string; + message: string; + callOutput?: string; + } +): Promise<{ capturedArgsPath: string; mockCodexPath: string }> { + const capturedArgsPath = path.join(repoDir, "captured-args.json"); + const mockCodexPath = path.join(repoDir, "mock-codex"); + const todayDir = path.join(sessionsDir, "2026", "03", "15"); + await fs.mkdir(todayDir, { recursive: true }); + await fs.writeFile( + mockCodexPath, + `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const cwd = process.cwd(); +const sessionsDir = process.env.CAM_CODEX_SESSIONS_DIR; +fs.writeFileSync(path.join(cwd, "captured-args.json"), JSON.stringify(process.argv.slice(2), null, 2)); +const rolloutDir = path.join(sessionsDir, "2026", "03", "15"); +fs.mkdirSync(rolloutDir, { recursive: true }); +const rolloutPath = path.join(rolloutDir, "rollout-2026-03-15T00-00-00-000Z-session.jsonl"); +fs.writeFileSync(rolloutPath, [ + JSON.stringify({ type: "session_meta", payload: { id: ${JSON.stringify(options.sessionId)}, timestamp: "2026-03-15T00:00:00.000Z", cwd } }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: ${JSON.stringify(options.message)} } }), + JSON.stringify({ type: "response_item", payload: { type: "function_call", name: "exec_command", call_id: "call-1", arguments: "{\\"cmd\\":\\"pnpm test\\"}" } }), + JSON.stringify({ type: "response_item", payload: { type: "function_call_output", call_id: "call-1", output: ${JSON.stringify(options.callOutput ?? "Process exited with code 0")} } }) +].join("\\n")); +`, + "utf8" + ); + await fs.chmod(mockCodexPath, 0o755); + + return { + capturedArgsPath, + mockCodexPath + }; +} + afterEach(async () => { process.env.CAM_CODEX_SESSIONS_DIR = originalSessionsDir; await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); describe("runSession", () => { + it("shows an empty compact prior preview when no continuity audit history exists", async () => { + const repoDir = await tempDir("cam-session-empty-history-repo-"); + const memoryRoot = await tempDir("cam-session-empty-history-memory-"); + await initRepo(repoDir); + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const loadOutput = await runSession("load", { cwd: repoDir }); + expect(loadOutput).toContain("Latest generation: none recorded yet"); + expect(loadOutput).toContain("Recent prior generations:"); + expect(loadOutput).toContain("- none recorded yet"); + + const statusOutput = await runSession("status", { cwd: repoDir }); + expect(statusOutput).toContain("Latest generation: none recorded yet"); + expect(statusOutput).toContain("Recent prior generations:"); + expect(statusOutput).toContain("- none recorded yet"); + }); + it("saves, loads, reports, and clears continuity state", async () => { const repoDir = await tempDir("cam-session-cmd-repo-"); const memoryRoot = await tempDir("cam-session-cmd-memory-"); @@ -241,8 +303,11 @@ describe("runSession", () => { const loadOutput = await runSession("load", { cwd: repoDir }); expect(loadOutput).toContain("Evidence: successful"); expect(loadOutput).toContain("Written paths:"); - expect(loadOutput).toContain("Recent generations:"); - expect(loadOutput).toContain(secondRolloutPath); + expect(loadOutput).toContain("Recent prior generations:"); + expect(loadOutput).toContain(rolloutPath); + expect( + loadOutput.match(new RegExp(secondRolloutPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? [] + ).toHaveLength(1); const statusJson = JSON.parse( await runSession("status", { cwd: repoDir, json: true }) @@ -267,8 +332,11 @@ describe("runSession", () => { const statusOutput = await runSession("status", { cwd: repoDir }); expect(statusOutput).toContain("Evidence: successful"); expect(statusOutput).toContain("Written paths:"); - expect(statusOutput).toContain("Recent generations:"); - expect(statusOutput).toContain(secondRolloutPath); + expect(statusOutput).toContain("Recent prior generations:"); + expect(statusOutput).toContain(rolloutPath); + expect( + statusOutput.match(new RegExp(secondRolloutPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) ?? [] + ).toHaveLength(1); const clearOutput = await runSession("clear", { cwd: repoDir, scope: "both" }); expect(clearOutput).toContain("Cleared session continuity files"); @@ -518,16 +586,166 @@ describe("runSession", () => { const loadOutput = await runSession("load", { cwd: repoDir }); expect(loadOutput).toContain("Evidence: successful 1 | failed 0 | file writes 0 | next steps 1 | untried 0"); expect(loadOutput).toContain("/tmp/continuity.md"); - expect(loadOutput).toContain("Recent generations:"); - expect(loadOutput).toContain("/tmp/rollout-good.jsonl"); + expect(loadOutput).toContain("Recent prior generations:"); + expect(loadOutput).toContain("- none beyond latest"); const statusOutput = await runSession("status", { cwd: repoDir }); expect(statusOutput).toContain("Evidence: successful 1 | failed 0 | file writes 0 | next steps 1 | untried 0"); expect(statusOutput).toContain("/tmp/continuity.md"); - expect(statusOutput).toContain("Recent generations:"); - expect(statusOutput).toContain("/tmp/rollout-good.jsonl"); + expect(statusOutput).toContain("Recent prior generations:"); + expect(statusOutput).toContain("- none beyond latest"); }, 30_000); + it("keeps latest continuity audit separate from compact prior history and coalesces repeated prior entries", async () => { + const repoDir = await tempDir("cam-session-compact-history-repo-"); + const memoryRoot = await tempDir("cam-session-compact-history-memory-"); + await initRepo(repoDir); + await writeProjectConfig( + repoDir, + configJson(), + { autoMemoryDirectory: memoryRoot } + ); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson(), + autoMemoryDirectory: memoryRoot + }); + await store.ensureAuditLayout(); + await fs.writeFile( + store.paths.auditFile, + [ + JSON.stringify({ + generatedAt: "2026-03-15T00:00:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-oldest.jsonl", + sourceSessionId: "session-oldest", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 1, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-oldest.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:01:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-repeat.jsonl", + sourceSessionId: "session-repeat", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 1, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-repeat.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:02:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-repeat.jsonl", + sourceSessionId: "session-repeat", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 1, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-repeat.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:03:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-prior-b.jsonl", + sourceSessionId: "session-prior-b", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 2, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-prior-b.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:04:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-prior-c.jsonl", + sourceSessionId: "session-prior-c", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 3, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-prior-c.md"] + } satisfies SessionContinuityAuditEntry), + JSON.stringify({ + generatedAt: "2026-03-15T00:05:00.000Z", + projectId: detectProjectContext(repoDir).projectId, + worktreeId: detectProjectContext(repoDir).worktreeId, + configuredExtractorMode: "heuristic", + scope: "both", + rolloutPath: "/tmp/rollout-latest.jsonl", + sourceSessionId: "session-latest", + preferredPath: "heuristic", + actualPath: "heuristic", + fallbackReason: "configured-heuristic", + evidenceCounts: { + successfulCommands: 4, + failedCommands: 0, + fileWrites: 0, + nextSteps: 1, + untried: 0 + }, + writtenPaths: ["/tmp/continuity-latest.md"] + } satisfies SessionContinuityAuditEntry) + ].join("\n"), + "utf8" + ); + + const loadOutput = await runSession("load", { cwd: repoDir }); + expect(loadOutput).toContain("Recent prior generations:"); + expect(loadOutput).toContain("/tmp/rollout-repeat.jsonl"); + expect(loadOutput).toContain("Repeated similar generations hidden: 1"); + expect(loadOutput).toContain("- older generations omitted: 1"); + expect(loadOutput.match(/\/tmp\/rollout-latest\.jsonl/g) ?? []).toHaveLength(1); + }); + it("skips invalid-shaped continuity audit entries", async () => { const repoDir = await tempDir("cam-session-invalid-shape-repo-"); const memoryRoot = await tempDir("cam-session-invalid-shape-memory-"); @@ -812,6 +1030,173 @@ describe("runSession", () => { }); describe("runWrappedCodex with session continuity", () => { + it("does not inject or auto-save continuity when both wrapper flags are disabled", async () => { + const repoDir = await tempDir("cam-wrapper-no-continuity-repo-"); + const memoryRoot = await tempDir("cam-wrapper-no-continuity-memory-"); + const sessionsDir = await tempDir("cam-wrapper-no-continuity-rollouts-"); + await initRepo(repoDir); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + + const { capturedArgsPath, mockCodexPath } = await writeWrapperMockCodex(repoDir, sessionsDir, { + sessionId: "session-wrapper-no-continuity", + message: "Continue without continuity automation." + }); + + await writeProjectConfig( + repoDir, + configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false + }), + { + autoMemoryDirectory: memoryRoot, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false + } + ); + + const exitCode = await runWrappedCodex(repoDir, "exec", ["continue"]); + expect(exitCode).toBe(0); + + const capturedArgs = JSON.parse(await fs.readFile(capturedArgsPath, "utf8")) as string[]; + const baseInstructionsArg = capturedArgs.find((arg) => arg.startsWith("base_instructions=")); + expect(baseInstructionsArg).toContain("# Codex Auto Memory"); + expect(baseInstructionsArg).not.toContain("# Session Continuity"); + + const store = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: false + }), + autoMemoryDirectory: memoryRoot + }); + expect(await store.readLatestAuditEntry()).toBeNull(); + expect(await store.readMergedState()).toBeNull(); + expect(await store.readRecoveryRecord()).toBeNull(); + }, 30_000); + + it("injects continuity without auto-saving when autoLoad is enabled and autoSave is disabled", async () => { + const repoDir = await tempDir("cam-wrapper-load-only-repo-"); + const memoryRoot = await tempDir("cam-wrapper-load-only-memory-"); + const sessionsDir = await tempDir("cam-wrapper-load-only-rollouts-"); + await initRepo(repoDir); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + + const { capturedArgsPath, mockCodexPath } = await writeWrapperMockCodex(repoDir, sessionsDir, { + sessionId: "session-wrapper-load-only", + message: "Continue but do not auto-save continuity." + }); + + await writeProjectConfig( + repoDir, + configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: true, + sessionContinuityAutoSave: false + }), + { + autoMemoryDirectory: memoryRoot, + sessionContinuityAutoLoad: true, + sessionContinuityAutoSave: false + } + ); + + const continuityStore = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: true, + sessionContinuityAutoSave: false + }), + autoMemoryDirectory: memoryRoot + }); + await continuityStore.saveSummary( + { + project: { + goal: "Seeded continuity goal.", + confirmedWorking: ["Seeded continuity still exists."], + triedAndFailed: [], + notYetTried: [], + incompleteNext: [], + filesDecisionsEnvironment: [] + }, + projectLocal: { + goal: "", + confirmedWorking: [], + triedAndFailed: [], + notYetTried: [], + incompleteNext: ["Seeded local next step."], + filesDecisionsEnvironment: [] + } + }, + "both" + ); + + const exitCode = await runWrappedCodex(repoDir, "exec", ["continue"]); + expect(exitCode).toBe(0); + + const capturedArgs = JSON.parse(await fs.readFile(capturedArgsPath, "utf8")) as string[]; + const baseInstructionsArg = capturedArgs.find((arg) => arg.startsWith("base_instructions=")); + expect(baseInstructionsArg).toContain("# Session Continuity"); + expect(baseInstructionsArg).toContain("Seeded continuity goal."); + + const merged = await continuityStore.readMergedState(); + expect(merged?.goal).toBe("Seeded continuity goal."); + expect(merged?.goal).not.toContain("do not auto-save continuity"); + expect(await continuityStore.readLatestAuditEntry()).toBeNull(); + expect(await continuityStore.readRecoveryRecord()).toBeNull(); + }, 30_000); + + it("auto-saves continuity without injecting it when autoLoad is disabled and autoSave is enabled", async () => { + const repoDir = await tempDir("cam-wrapper-save-only-repo-"); + const memoryRoot = await tempDir("cam-wrapper-save-only-memory-"); + const sessionsDir = await tempDir("cam-wrapper-save-only-rollouts-"); + await initRepo(repoDir); + process.env.CAM_CODEX_SESSIONS_DIR = sessionsDir; + + const { capturedArgsPath, mockCodexPath } = await writeWrapperMockCodex(repoDir, sessionsDir, { + sessionId: "session-wrapper-save-only", + message: "Continue with save-only continuity handling." + }); + + await writeProjectConfig( + repoDir, + configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + }), + { + autoMemoryDirectory: memoryRoot, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + } + ); + + const continuityStore = new SessionContinuityStore(detectProjectContext(repoDir), { + ...configJson({ + codexBinary: mockCodexPath, + sessionContinuityAutoLoad: false, + sessionContinuityAutoSave: true + }), + autoMemoryDirectory: memoryRoot + }); + + const exitCode = await runWrappedCodex(repoDir, "exec", ["continue"]); + expect(exitCode).toBe(0); + + const capturedArgs = JSON.parse(await fs.readFile(capturedArgsPath, "utf8")) as string[]; + const baseInstructionsArg = capturedArgs.find((arg) => arg.startsWith("base_instructions=")); + expect(baseInstructionsArg).toContain("# Codex Auto Memory"); + expect(baseInstructionsArg).not.toContain("# Session Continuity"); + + const latestAudit = await continuityStore.readLatestAuditEntry(); + expect(latestAudit?.rolloutPath).toContain("rollout-2026-03-15T00-00-00-000Z-session.jsonl"); + const merged = await continuityStore.readMergedState(); + expect(merged?.goal).toContain("Continue with save-only continuity handling"); + }, 30_000); + it("injects continuity on startup and auto-saves it after the run", async () => { const repoDir = await tempDir("cam-wrapper-session-repo-"); const memoryRoot = await tempDir("cam-wrapper-session-memory-"); diff --git a/test/session-continuity.test.ts b/test/session-continuity.test.ts index 7542923..5b9d6eb 100644 --- a/test/session-continuity.test.ts +++ b/test/session-continuity.test.ts @@ -1030,7 +1030,7 @@ describe("SessionContinuityStore", () => { expect((await store.readState("project-local"))?.incompleteNext).toContain("Add middleware."); }); - it("supports claude-style local files and clears all active local session tmp files", async () => { + it("supports claude-style local files, reads the newest mtime file, and clears all active session tmp files", async () => { const repoDir = await tempDir("cam-continuity-claude-repo-"); const memoryRoot = await tempDir("cam-continuity-claude-memory-"); await initRepo(repoDir); @@ -1062,13 +1062,21 @@ describe("SessionContinuityStore", () => { ); await store.ensureLocalLayout(); const olderFile = path.join(store.paths.localDir, "2026-03-01-old-session.tmp"); + const newerFile = path.join(store.paths.localDir, "2026-03-02-new-session.tmp"); await fs.writeFile(olderFile, "stale\n", "utf8"); + await fs.writeFile(newerFile, "fresh\n", "utf8"); + const olderTimestamp = new Date("2026-03-01T00:00:00.000Z"); + const newerTimestamp = new Date("2026-03-03T00:00:00.000Z"); + await fs.utimes(olderFile, olderTimestamp, olderTimestamp); + await fs.utimes(store.paths.localFile, olderTimestamp, olderTimestamp); + await fs.utimes(newerFile, newerTimestamp, newerTimestamp); const location = await store.getLocation("project-local"); - expect(location.path.endsWith("-session.tmp")).toBe(true); + expect(location.path).toBe(newerFile); const cleared = await store.clear("project-local"); expect(cleared).toContain(olderFile); + expect(cleared).toContain(newerFile); expect((await fs.readdir(store.paths.localDir)).filter((name) => name.endsWith("-session.tmp"))).toHaveLength(0); }); });