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
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export SHLOG_DATA_DIR="$HOME/.config/shlog"
```

Sync is strict by default. If any selected file fails to parse or write, `sync` exits non-zero with per-file diagnostics and does not commit partial coverage. Codex active-session append is the narrow exception to the old “source must stay byte-for-byte static” rule: each Codex file is read only through the byte boundary captured for the sync, and a verified append after that boundary does not fail the command. Stable sources and the bounded active prefix are committed together; JSON reports `coverage.staleReason: "source_content_changed"` and `recommendedAction: "query"`, and a later sync fills the tail. If an unindexed Codex file already changed before its bounded read opened, sync cannot prove that the old prefix was append-only; it defers that file and complete coverage while committing other stable operations, and reports `coverage.reason: "active_source_deferred"` with `recommendedAction: "sync"`. Truncation, verified prefix rewrite/replacement, source-file-set changes, and mid-sync changes from other sources remain strict failures. Previously indexed sessions whose source JSONL later disappears are retained by default, so raw log maintenance does not make historical `shlog find` or `read-*` results disappear.

`find --json` evaluates selector freshness against the current raw source and reports coverage from that same assessment. `complete=false` with `freshness="stale"` does not make existing indexed hits unavailable: a non-empty Codex result remains usable when only the active tail changed, while a zero result receives a retry-oriented `nextAction`. Library-level synchronous queries that only read SQLite return `complete=false` / `freshness="not_checked"` and preserve `coveringSelectors` rather than claiming current completeness without inspecting raw files.
Pass `--prune` only when you explicitly want to delete indexed sessions that are no longer present in the selected source snapshot.
Pass `--best-effort` only when you explicitly want successful files written despite failures; best-effort sync does not record complete coverage.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@act0r/sherlog",
"version": "0.3.18",
"version": "0.3.19",
"type": "module",
"description": "Progressive search CLI for local Codex, Claude Code, and Pi session logs",
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion skill-packages/sherlog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ description: "Use proactively for local Codex history and personal setup archaeo

- `index_unavailable`: 普通首次安装可 `sync`;明确项目范围优先 `sync --cwd <path>`。
- `session_not_found`: 只说明当前 index 没有这个 `sessionRef`;按 `nextAction` 检查 source/id/coverage,必要时同 source scoped sync 后重试。
- `stale_or_missing_coverage`: 先判断是否需要完整结论。Codex `source_content_changed` + `recommendedAction: "query"` 常是活跃尾部软 stale,可先 query/read;coverage 缺失、source set 变化、非 Codex 保守同步、零结果可疑或用户要求完整性时,按提示同范围 sync 后重试。
- `stale_or_missing_coverage`: 先判断是否需要完整结论。`find --json` 的 coverage 会做 live freshness 评估;`complete=false` 只表示不能证明当前 raw snapshot 已完整覆盖,不表示已有索引不可查询。Codex `source_content_changed` 常是活跃尾部软 stale,非空结果可先 query/read;coverage 缺失、source set 变化、非 Codex 保守同步、零结果可疑或用户要求完整性时,按 `nextAction` 同范围 sync 后重试。
- `sync` 成功但 `coverage.staleReason: "source_content_changed"`: Codex 活跃 JSONL 在读取后继续追加;已读边界和其他稳定 source 已安全落库,可继续 query/read,稍后再 sync 补尾部。截断、前缀改写和 source set 变化仍是失败,不要把它们当成同一类软 stale。
- `sync` 成功但 `coverage.reason: "active_source_deferred"`: 尚未索引的新 Codex 文件在有界读取前已变化,无法证明是纯追加;该文件和 complete coverage 被保守延后,其他稳定 source 已落库。按 `recommendedAction: "sync"` 重试,不要把本轮结果当完整覆盖。
- fresh coverage 下仍无结果,才说没找到。
Expand Down
2 changes: 1 addition & 1 deletion skill-packages/sherlog/references/cli-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ text header 带效率回述:`shlog find "q" · 检索 ~N 条 · 结果 R · Xms`

效率回述默认开,环境变量 `SHLOG_STATS=0`(或 `off`/`false`/`no`)可关闭文本 header 里的注解(`检索 ~N 条 / 读取 K 条 / Xms`);`--json` 的 `scannedMessageCount` / `elapsedMs` 与 `read-page` 的 `total/hasMore` 等功能字段始终保留。关闭时文本里没有可锚的数字,直接省掉效率尾注、别硬编。

零结果不是结束条件。`--json` 下如果返回 `nextAction`,按它选择/检查同一 selector;text 输出也会打印 `next:` 步骤。新版 Codex `find` 对非空结果会忽略 `source_content_changed` / `recommendedAction: "query"` 软 stale,避免当前会话尾部变化反复逼 agent 同步。若非空结果仍返回 `nextAction.reason=stale_or_missing_coverage`,通常是 coverage 缺失、source file 集合变化或非 Codex source 保守同步;需要完整结论时按 `nextAction.commands` 同步并重试。否则只有 `status.requestedCoverage.recommendedAction === "sync"` 时才跑同范围 `sync`。fresh coverage 下仍无结果,才可以说没找到。
零结果不是结束条件。`find --json` 会在同一次调用中评估 raw source freshness,因此 `coverage.complete` / `freshness` / `staleReason` 与 `nextAction` 使用同一 snapshot;纯 SQLite query facade 无法检查 raw 时则诚实返回 `complete=false` / `freshness=not_checked`,但保留 `coveringSelectors`。`--json` 下如果返回 `nextAction`,按它选择/检查同一 selector;text 输出也会打印 `next:` 步骤。Codex `find` 对非空结果会忽略 `source_content_changed` 软 stale,避免当前会话尾部变化反复逼 agent 同步;此时 `complete=false` 表示最新尾部未获证明,不表示已有索引不可查询。若非空结果仍返回 `nextAction.reason=stale_or_missing_coverage`,通常是 coverage 缺失、source file 集合变化或非 Codex source 保守同步;需要完整结论时按 `nextAction.commands` 同步并重试。fresh coverage 下仍无结果,才可以说没找到。

Example:

Expand Down
12 changes: 11 additions & 1 deletion skill-packages/sherlog/references/json-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,11 +293,21 @@ Input selector JSON may omit `source`; canonical selectors returned by public CL
{
requested: Selector | null;
complete: boolean;
freshness: "not_checked";
freshness: "fresh" | "stale" | "missing" | "not_checked";
staleReason?: "none" | "missing" | "source_content_changed" | "source_set_changed";
coveringSelectors: CoverageRecord[];
}
```

Public `find --json` evaluates raw source freshness and keeps these fields aligned
with `nextAction`. The synchronous SQLite query facade cannot inspect raw source
state, so it returns `complete=false` / `freshness="not_checked"` while retaining
historical `coveringSelectors`; that means freshness is unconfirmed, not that the
indexed rows are unusable. For a Codex active-tail soft stale, non-empty results
remain queryable with `complete=false`, `freshness="stale"`, and
`staleReason="source_content_changed"`; a suspicious zero result also carries a
retry-oriented `nextAction`.

`RequestedCoverageStatus`:

```ts
Expand Down
65 changes: 63 additions & 2 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, test } from "vitest";
import { spawn as childSpawn } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { appendFileSync, chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
Expand Down Expand Up @@ -858,10 +858,71 @@ describe("shlog cli", { timeout: 20_000 }, () => {
coverage: { complete: boolean; freshness: string };
};
expect(findPayload.coverage.complete).toBe(true);
expect(findPayload.coverage.freshness).toBe("not_checked");
expect(findPayload.coverage.freshness).toBe("fresh");
expect(findPayload.results.map((result) => result.cwd)).toEqual(["/tmp/alpha"]);
});

test("find reports live soft-stale coverage without hiding usable indexed results", async () => {
const base = mkdtempSync(join(tmpdir(), "cxs-cli-find-soft-stale-"));
tempDirs.push(base);
const root = join(base, "sessions");
const day = join(root, "2026", "07", "12");
mkdirSync(day, { recursive: true });
const sessionPath = join(day, "rollout-2026-07-12T10-00-00-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa.jsonl");
writeFileSync(sessionPath, [
line("session_meta", { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", cwd: "/tmp/find-soft-stale" }),
line("event_msg", { type: "user_message", message: "indexed active prefix" }),
].join("\n"));

const dbPath = join(base, "index.sqlite");
const synced = await runCli(["sync", "--source", "codex", "--root", root, "--db", dbPath, "--json"]);
expect(synced.exitCode).toBe(0);

const freshMiss = await runCli(["find", "unindexed active tail", "--source", "codex", "--root", root, "--db", dbPath, "--json"]);
expect(freshMiss.exitCode).toBe(0);
const freshMissPayload = JSON.parse(freshMiss.stdout) as {
results: unknown[];
coverage: { complete: boolean; freshness: string; staleReason?: string };
nextAction?: unknown;
};
expect(freshMissPayload.results).toHaveLength(0);
expect(freshMissPayload.coverage).toMatchObject({ complete: true, freshness: "fresh", staleReason: "none" });
expect(freshMissPayload.nextAction).toBeUndefined();

appendFileSync(sessionPath, `\n${line("event_msg", { type: "agent_message", message: "unindexed active tail" })}`);

const found = await runCli(["find", "indexed active prefix", "--source", "codex", "--root", root, "--db", dbPath, "--json"]);
expect(found.exitCode).toBe(0);
const foundPayload = JSON.parse(found.stdout) as {
results: unknown[];
coverage: { complete: boolean; freshness: string; staleReason?: string };
nextAction?: unknown;
};
expect(foundPayload.results).toHaveLength(1);
expect(foundPayload.coverage).toMatchObject({
complete: false,
freshness: "stale",
staleReason: "source_content_changed",
});
expect(foundPayload.nextAction).toBeUndefined();

const missedTail = await runCli(["find", "unindexed active tail", "--source", "codex", "--root", root, "--db", dbPath, "--json"]);
expect(missedTail.exitCode).toBe(0);
const missedTailPayload = JSON.parse(missedTail.stdout) as {
results: unknown[];
coverage: { complete: boolean; freshness: string; staleReason?: string };
nextAction?: { reason: string; steps: string[] };
};
expect(missedTailPayload.results).toHaveLength(0);
expect(missedTailPayload.coverage).toMatchObject({
complete: false,
freshness: "stale",
staleReason: "source_content_changed",
});
expect(missedTailPayload.nextAction?.reason).toBe("stale_or_missing_coverage");
expect(missedTailPayload.nextAction?.steps[0]).toContain("existing source-file content");
});

test("sync and find support --cwd/--root without handwritten selector JSON", async () => {
const base = mkdtempSync(join(tmpdir(), "cxs-cli-cwd-shortcut-"));
tempDirs.push(base);
Expand Down
104 changes: 62 additions & 42 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { Command } from "commander";
import packageJson from "../package.json" with { type: "json" };
import {
DEFAULT_DB_PATH,
isCurrentIndexVersion,
migrateLegacyDataDirIfNeeded,
PROGRAM_NAME,
statsReadoutEnabled,
} from "./env";
import { IndexSchemaUpgradeRequiredError, IndexUnavailableError, listCoverageRecords, withReadDb } from "./db";
import { evaluateCoverageRecord, evaluateRequestedCoverage } from "./coverage-freshness";
import { buildEvidenceReadAction } from "./evidence-read";
import { getSessionSourceAdapter, listSessionSourceAdapters } from "./sources";

Expand All @@ -35,10 +35,11 @@ import {
listSessionSummaries,
SessionNotFoundError,
} from "./query";
import { canonicalizeSelector, parseSelectorJson, SelectorParseError, selectorImplies, selectorSource } from "./selector";
import { canonicalizeSelector, parseSelectorJson, SelectorParseError, selectorSource } from "./selector";
import { collectStatus } from "./status";
import { SyncLockTimeoutError } from "./sync-lock";
import type {
CoverageStatus,
FindResult,
FindSort,
FindSummary,
Expand Down Expand Up @@ -171,8 +172,19 @@ program
excludeSessions: options.excludeSession ?? [],
});
const coverageSelector = selector ?? defaultAllSelector(sourceId);
const coverageAction = await buildFindCoverageNextAction(options.db, coverageSelector, "this find", summary.results.length);
return coverageAction ? { ...summary, nextAction: coverageAction } : summary;
const coverageAssessment = await assessFindCoverage(
options.db,
coverageSelector,
"this find",
summary.results.length,
summary.coverage,
);
return {
...summary,
coverage: coverageAssessment.coverage,
coverageBySource: [{ sourceId, coverage: coverageAssessment.coverage }],
nextAction: coverageAssessment.nextAction,
};
}));
const result = mergeFindSummaries(query, sort, options.excludeSession ?? [], summaries, limit);
// performance.now() 自 timeOrigin(进程启动)起算 ≈ 本次端到端耗时,
Expand Down Expand Up @@ -469,7 +481,7 @@ function mergeFindSummaries(
const coverage = {
requested: null,
complete: coverageBySource.every((entry) => entry.coverage.complete),
freshness: "not_checked" as const,
freshness: mergedCoverageFreshness(coverageBySource.map((entry) => entry.coverage)),
coveringSelectors: coverageBySource.flatMap((entry) => entry.coverage.coveringSelectors),
};
const coverageActions = summaries
Expand Down Expand Up @@ -564,60 +576,68 @@ function buildCrossSourceZeroResultsNextAction(): QueryNextAction {
};
}

async function buildFindCoverageNextAction(
async function assessFindCoverage(
dbPath: string,
selector: Selector,
commandLabel: string,
resultCount: number,
): Promise<QueryNextAction | undefined> {
if (!existsSync(dbPath)) return undefined;
unconfirmedCoverage: CoverageStatus,
): Promise<{ coverage: CoverageStatus; nextAction?: QueryNextAction }> {
if (!existsSync(dbPath)) return { coverage: unconfirmedCoverage };

const sourceId = selectorSource(selector);
const source = getSessionSourceAdapter(sourceId);
const files = await source.collectFiles(selector.root);
const snapshot = await source.snapshotFromFiles(selector, files);
const coveringSelectors = withReadDb(dbPath, (db) =>
listCoverageRecords(db, sourceId).filter((entry) =>
isCurrentIndexVersion(entry.indexVersion) && selectorImplies(entry.selector, snapshot.selector)
)
);
let staleReason: RequestedCoverageStatus["staleReason"] = coveringSelectors.length > 0 ? "source_set_changed" : "missing";
for (const entry of coveringSelectors) {
const coverageRecords = withReadDb(dbPath, (db) => listCoverageRecords(db, sourceId));
const coverageInventory = [];
for (const entry of coverageRecords) {
const entrySnapshot = await source.snapshotFromFiles(entry.selector, files);
if (
entrySnapshot.fingerprint === entry.sourceFingerprint
&& (entry.sourceFileSetFingerprint === "" || entrySnapshot.fileSetFingerprint === entry.sourceFileSetFingerprint)
&& entrySnapshot.fileCount === entry.sourceFileCount
) {
return undefined;
}
if (entry.sourceFileSetFingerprint !== "" && entrySnapshot.fileSetFingerprint === entry.sourceFileSetFingerprint) {
staleReason = "source_content_changed";
}
coverageInventory.push(evaluateCoverageRecord(entry, entrySnapshot));
}
if (sourceId === "codex" && staleReason === "source_content_changed" && resultCount > 0) return undefined;
const requestedCoverage = evaluateRequestedCoverage(snapshot, coverageInventory);
const staleReason = requestedCoverage.staleReason;
const coverage: CoverageStatus = {
requested: requestedCoverage.requested,
complete: requestedCoverage.complete,
freshness: requestedCoverage.freshness,
staleReason,
coveringSelectors: requestedCoverage.coveringSelectors,
};
if (requestedCoverage.freshness === "fresh") return { coverage };
if (sourceId === "codex" && staleReason === "source_content_changed" && resultCount > 0) return { coverage };

const syncArgv = syncArgvForSelector(snapshot.selector);
return {
kind: "check_coverage_then_retry",
reason: "stale_or_missing_coverage",
selector: snapshot.selector,
steps: [
indexedCoverageStep(staleReason),
`Run ${syncArgv.join(" ")}.`,
`Retry ${commandLabel} before treating current results as complete.`,
],
commands: [
{
label: "refresh selector coverage",
recommended: true,
argv: syncArgv,
selector: snapshot.selector,
},
],
coverage,
nextAction: {
kind: "check_coverage_then_retry",
reason: "stale_or_missing_coverage",
selector: snapshot.selector,
steps: [
indexedCoverageStep(staleReason),
`Run ${syncArgv.join(" ")}.`,
`Retry ${commandLabel} before treating current results as complete.`,
],
commands: [
{
label: "refresh selector coverage",
recommended: true,
argv: syncArgv,
selector: snapshot.selector,
},
],
},
};
}

function mergedCoverageFreshness(coverages: CoverageStatus[]): CoverageStatus["freshness"] {
if (coverages.every((coverage) => coverage.freshness === "fresh")) return "fresh";
if (coverages.some((coverage) => coverage.freshness === "missing")) return "missing";
if (coverages.some((coverage) => coverage.freshness === "stale")) return "stale";
return "not_checked";
}

function indexedCoverageStep(staleReason: RequestedCoverageStatus["staleReason"]): string {
if (staleReason === "missing") {
return "Indexed coverage for this selector is missing; results may be incomplete or misleading.";
Expand Down
Loading