fix(autoplan): bounded, append-tolerant owned-journal read (A2)

The publication guard no longer reads the whole parent journal or refuses
it at 32 MiB. lib/claude-owned-journal.ts streams the bytes present at open
to the last complete line (pass one), keeps a content-free index per record
and decides ownership, ancestry and causal order on it with the same checks
and reason codes as before. Pass two reloads in full only what the
evaluation reads (autoplan/bin/guard-journal.ts): every literal init and its
result, the current call, and inside the invocation window (from the latest
human turn at or before the bound chain's first init) guarded and
close-packet Read results, structured Bash results, error results, assistant
text after the first close Read and active-plan mutations, plus the earlier
Read a dedup reply cites even before the window (ENG-4).

- lib/claude-journal-records.ts: per-record event extraction, record
  metadata and the causal-order checks, shared by readPlanCountTranscript
  and the bounded reader. The batch reader keeps its whole-file read and cap.
- Appends after open never fail a read; same device and inode and no
  shrink are required (identity). A second read in one hook invocation must
  hash bytes [0, first size) the same, or it is `rewritten` (UC1 unverified);
  the unrecognized-shape double read uses that prefix hash. `changing` is
  removed (REMOVED_CODES; its anchor stays).
- OWNED_RECORD_MAX_BYTES (32 MiB): only one record over it is too_large.
  OWNED_RETAINED_MAX_BYTES (32 MiB): over it is the new oversized_invocation
  fallback denial. GSTACK_TRANSCRIPT_TEST_MAX_BYTES and
  GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only lower them.
- ENG-5: evaluation groups uses and results by tool id once (no per-tool
  rescans).

CEO-25 / ENG-5 spike (120 MiB synthetic, 31,242 records, 4 vCPU Linux,
Bun 1.4.2): a full streaming rescan (parse every line, small index, sha256)
took 254-326 ms at 147-161 MiB peak RSS; a pass-one index cache under the
state root (load cached index, verify a 64 KiB tail hash, parse the tail)
took 26-29 ms at 47 MiB. The rescan meets the 1 s budget, so the cache is
not adopted: it would add trusted persistent state and invalidation for no
budget need. A backward tail read cannot prove competing_root or cycle, so
it was not a candidate.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-07 21:03:31 +00:00
1 parent 8c11ebc519
commit cd12cb207a
13 files changed
+1140 -510

No files matched your search

+374
View File
@@ -0,0 +1,374 @@
/**
* One reading of a Claude Code journal record, shared by the batch reader
* (`readPlanCountTranscript`) and the guard's bounded owned reader
* (`lib/claude-owned-journal.ts`), so the two cannot drift: the public events a
* record yields, the metadata its ownership is judged on, and the ownership,
* ancestry and causal-order checks over that metadata.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
/** Optional public tool projection for the Autoplan delivery audit; never thinking. */
export interface NativePublicToolEvent {
sessionId: string;
timestamp: string;
toolUseId: string;
kind: 'use' | 'result';
name?: string;
/** Exact native message/request identity, used only for owned queued tools. */
messageId?: string;
requestId?: string;
input?: Record<string, unknown>;
content?: unknown;
file?: unknown;
isError?: boolean;
/** Owned reads only: Claude Code launched this Agent in the background (its result is a launch receipt). */
async?: boolean;
}
/** Hook-only verified causal order (physical order for independent ready records). The existing fixture projection remains unchanged. */
export type ClaudeParentPublicEvent = (NativePublicToolEvent | {
kind: 'message'; sessionId: string; timestamp: string; text: string;
} | {
kind: 'end_turn' | 'user_turn'; sessionId: string; timestamp: string; autoplan?: boolean;
} | {
/** Claude Code's completion notice for a background Agent (origin kind task-notification). */
kind: 'task_notification'; sessionId: string; timestamp: string; notifiedToolUseId: string;
}) & { order: number; messageId?: string; requestId?: string };
/**
* Why an owned journal supplied no owned lines (docs/autoplan-guard-troubleshooting.md).
* Positive identity conflicts are hard; only `unrecognized_shape:*` may degrade to an advisory.
*/
export type OwnedTranscriptReason = 'competing_root' | 'foreign_cwd' | 'sidechain' | 'agent' | 'cycle' | 'too_large' |
'rewritten' | 'oversized_invocation' | 'identity' | 'malformed' | `unrecognized_shape:${string}`;
export const object = (value: unknown): value is Record<string, any> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
export const validTimestamp = (value: unknown): value is string =>
typeof value === 'string' && Number.isFinite(Date.parse(value));
/** A record's own Claude Code version string, shape-checked; never other content. */
export const claudeVersionOf = (record: Record<string, any>): string | undefined =>
typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version) ? record.version : undefined;
const versions = new Map<string, string | undefined>();
/** claudeVersionOf for a record's index entry: one check per distinct version string. */
const indexedVersion = (record: Record<string, any>): string | undefined => {
if (typeof record.version !== 'string') return;
if (!versions.has(record.version) && versions.size < 64) versions.set(record.version, claudeVersionOf(record));
return versions.has(record.version) ? versions.get(record.version) : claudeVersionOf(record);
};
/** A native record uuid: 8-4-4-4-12 hex digits (checked per character; every record has two). */
export const nativeUuid = (value: unknown): value is string => {
if (typeof value !== 'string' || value.length !== 36) return false;
for (let i = 0; i < 36; i++) {
const c = value.charCodeAt(i);
if (i === 8 || i === 13 || i === 18 || i === 23) { if (c !== 45) return false; }
else if (!((c >= 48 && c <= 57) || (c >= 97 && c <= 102) || (c >= 65 && c <= 70))) return false;
}
return true;
};
/**
* Windows spells one native path C:\, c:\, C:/ or Git Bash /c/ (hook env,
* journal and tool input disagree). Fold separators, the MSYS drive prefix and
* drive-letter case only. No resolve: `.`, `..` and doubled separators stay
* distinct, so a normalize check after the fold still rejects them. The path
* module is a parameter so win32 call sites are unit-testable on any host.
*/
export function nativePathSpelling(value: string, p: typeof path = path): string {
if (p.sep !== '\\') return value;
return value.replace(/^\/([A-Za-z])(?=\/|$)/, '$1:').replaceAll('/', '\\')
.replace(/^[a-z](?=:)/, drive => drive.toUpperCase());
}
/** Validation-then-compare: absolute, and its folded spelling is already normal. */
export function ownedNativePath(value: unknown, p: typeof path = path): value is string {
if (typeof value !== 'string') return false;
const folded = nativePathSpelling(value, p);
return p.isAbsolute(folded) && p.normalize(folded) === folded;
}
export function sameNativePath(a: unknown, b: unknown, p: typeof path = path): boolean {
return typeof a === 'string' && typeof b === 'string' && nativePathSpelling(a, p) === nativePathSpelling(b, p);
}
function sameRealPath(a: unknown, b: unknown): boolean {
try { return typeof a === 'string' && typeof b === 'string' && sameNativePath(fs.realpathSync(a), fs.realpathSync(b)); }
catch { return false; }
}
/** Read one length-delimited protobuf field, rejecting malformed/ambiguous input. */
function signatureField(bytes: Uint8Array | undefined, wanted: number): Uint8Array | undefined {
if (!bytes) return;
let cursor = 0;
let result: Uint8Array | undefined;
let seen = false;
const integer = () => {
let value = 0;
for (let shift = 0; shift < 70; shift += 7) {
if (cursor >= bytes.length) throw new Error('truncated signature');
const byte = bytes[cursor++]!;
value += (byte & 127) * 2 ** shift;
if (!Number.isSafeInteger(value)) throw new Error('signature integer overflow');
if (!(byte & 128)) return value;
}
throw new Error('overlong signature integer');
};
while (cursor < bytes.length) {
const key = integer();
const field = Math.floor(key / 8);
if (field < 1 || field > 0x1fffffff) throw new Error('invalid signature field');
if (field === wanted) {
if (seen) throw new Error('duplicate signature field');
seen = true;
}
switch (key % 8) {
case 0: integer(); break;
case 1: cursor += 8; break;
case 2: {
const length = integer();
if (length > bytes.length - cursor) throw new Error('truncated signature field');
if (field === wanted) result = bytes.subarray(cursor, cursor + length);
cursor += length;
break;
}
case 5: cursor += 4; break;
default: throw new Error('unsupported signature wire type');
}
if (cursor > bytes.length) throw new Error('truncated signature field');
}
return result;
}
/**
* Claude's public narration renderer classifies signature fields 2→1→8 as
* block_kind="narration": summaries of inter-tool prose, not private reasoning.
* Match that metadata only in this already-owned native transcript. This is
* classification, not cryptographic signature verification. Never read the
* thinking text of an untagged, unknown, malformed or legacy block.
*/
function publicNarrationText(block: Record<string, any>): string | undefined {
if (block.type !== 'thinking' || typeof block.signature !== 'string' ||
block.signature.length > 64 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(block.signature)) return;
try {
const bytes = Buffer.from(block.signature, 'base64');
const canonical = bytes.toString('base64');
if (block.signature !== canonical && block.signature !== canonical.replace(/=+$/, '')) return;
const tag = signatureField(signatureField(signatureField(bytes, 2), 1), 8);
if (!tag || Buffer.from(tag).toString('utf8') !== 'narration') return;
return typeof block.thinking === 'string' && block.thinking.trim() ? block.thinking : undefined;
} catch { return; }
}
const AUTOPLAN_SLASH = /^<command-message>autoplan<\/command-message>\n<command-name>\/autoplan<\/command-name>(?:\n<command-args>[\s\S]*<\/command-args>)?$/;
/**
* The public events one record yields, for a record its reader already
* accepted (it carries a message object). `owned` adds the hook-only events
* (user turns, end_turn, background completions) and each event's position
* inside the record as `order`; the caller adds the record's base. The batch
* projection (`owned` false) keeps its validated batch identity and no order.
*/
export function recordEvents(record: Record<string, any>, owned: boolean,
timestampValid = validTimestamp(record.timestamp)): { events: ClaudeParentPublicEvent[]; slots: number } {
const events: ClaudeParentPublicEvent[] = [], message = record.message, sessionId = record.sessionId, timestamp = record.timestamp;
let slot = 0;
if (owned && message.role === 'user') {
const notified = record.origin?.kind === 'task-notification' && typeof message.content === 'string'
? /<tool-use-id>([A-Za-z0-9_-]{1,160})<\/tool-use-id>/.exec(message.content)?.[1] : undefined;
if (notified) events.push({ kind: 'task_notification', sessionId, timestamp, order: slot++, notifiedToolUseId: notified });
if (record.origin?.kind === 'human' && record.isMeta !== true && nativeUuid(record.promptId) && typeof message.content === 'string') {
const autoplan = AUTOPLAN_SLASH.test(message.content);
if (record.promptSource === 'typed' || autoplan) events.push({ kind: 'user_turn', sessionId, timestamp, order: slot++, autoplan });
}
}
if (!Array.isArray(message.content)) return { events, slots: slot };
if (!timestampValid) slot += message.content.length;
else for (const block of message.content) {
const order = slot++;
if (!object(block)) continue;
let event: Record<string, unknown> | undefined;
if (message.role === 'assistant' && block.type === 'tool_use' &&
typeof block.id === 'string' && typeof block.name === 'string' && object(block.input)) {
event = { sessionId, timestamp, toolUseId: block.id, kind: 'use', name: block.name, input: block.input };
if (!owned && typeof message.id === 'string' && /^msg_[A-Za-z0-9_-]{1,160}$/.test(message.id) &&
typeof record.requestId === 'string' && /^req_[A-Za-z0-9_-]{1,160}$/.test(record.requestId)) {
event.messageId = message.id; event.requestId = record.requestId;
}
} else if (message.role === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
event = { sessionId, timestamp, toolUseId: block.tool_use_id, kind: 'result', content: block.content,
file: record.toolUseResult?.file, isError: block.is_error === true };
if (owned && record.toolUseResult?.isAsync === true) event.async = true;
} else if (message.role === 'assistant') {
const text = block.type === 'text' && typeof block.text === 'string' && block.text.trim() ? block.text : publicNarrationText(block);
if (text) event = { kind: 'message', sessionId, text, timestamp };
}
if (!event) continue;
if (owned) {
event.order = order;
if (typeof message.id === 'string') event.messageId = message.id;
if (typeof record.requestId === 'string') event.requestId = record.requestId;
}
events.push(event as unknown as ClaudeParentPublicEvent);
}
if (owned && message.role === 'assistant' && message.stop_reason === 'end_turn')
events.push({ kind: 'end_turn', sessionId, timestamp, order: slot++, ...(typeof message.id === 'string' ? { messageId: message.id } : {}) });
return { events, slots: slot };
}
/** What the ownership checks read from one record: metadata, never content. */
export interface RecordMeta {
/** Position in the metas array (physical order among the journal's records); breaks causal-order ties. */
index: number;
uuid?: string;
/** parentUuid, or a compact boundary's logicalParentUuid. */
parent?: string;
parentNull: boolean;
/** Parent-session metadata: no agentId, isSidechain false, absolute cwd, native uuid, valid timestamp. */
node: boolean;
agent: boolean;
sidechain: boolean;
/** The record opens a user or assistant turn outside any subagent. */
conversation: boolean;
message: boolean;
role?: string;
sessionStart: boolean;
timestampValid: boolean;
cwd?: string;
type?: string;
subtype?: string;
attachmentType?: string;
version?: string;
}
/** Claude writes each SessionStart hook result as a message-less attachment chained above the first user turn. */
const sessionStartRecord = (r: Record<string, any>): boolean => r.type === 'attachment' && r.message == null &&
object(r.attachment) && r.attachment.hookEvent === 'SessionStart' &&
typeof r.attachment.type === 'string' && /^hook_[a-z_]{1,48}$/.test(r.attachment.type);
/** Record type names only, never content: `attachment:hook_success`, `system:compact_boundary`. */
export const shapeOf = (m: RecordMeta): string => [m.type, m.subtype, m.attachmentType]
.filter(x => typeof x === 'string' && x).map(x => x!.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)).join(':') || 'untyped';
/** Why a record that is not a parent-session node cannot carry ownership. */
const excludedOf = (m: RecordMeta): OwnedTranscriptReason =>
m.agent ? 'agent' : m.sidechain ? 'sidechain' : 'unrecognized_shape:record_metadata';
const cwds = new Map<string, string>();
export function recordMeta(r: Record<string, any>, index: number): RecordMeta {
const message = object(r.message);
// One shared string per distinct cwd keeps a long journal's index small.
let cwd: string | undefined = typeof r.cwd === 'string' ? cwds.get(r.cwd) : undefined;
if (cwd === undefined && typeof r.cwd === 'string') { cwd = r.cwd; if (cwds.size < 256) cwds.set(r.cwd, r.cwd); }
const uuid = nativeUuid(r.uuid) ? r.uuid : undefined, timestampValid = validTimestamp(r.timestamp);
return {
index, uuid,
parent: nativeUuid(r.parentUuid) ? r.parentUuid : r.parentUuid === null && r.type === 'system' && r.subtype === 'compact_boundary' &&
r.message == null && nativeUuid(r.logicalParentUuid) ? r.logicalParentUuid : undefined,
parentNull: r.parentUuid === null,
node: r.agentId == null && r.isSidechain === false && cwd !== undefined && path.isAbsolute(cwd) && uuid !== undefined && timestampValid,
agent: r.agentId != null, sidechain: r.isSidechain !== false,
conversation: r.agentId == null && message && (r.message.role === 'user' || r.message.role === 'assistant'),
message, role: message && typeof r.message.role === 'string' ? r.message.role : undefined,
sessionStart: sessionStartRecord(r), timestampValid, cwd,
type: typeof r.type === 'string' ? r.type : undefined, subtype: typeof r.subtype === 'string' ? r.subtype : undefined,
attachmentType: object(r.attachment) && typeof r.attachment.type === 'string' ? r.attachment.type : undefined,
version: indexedVersion(r),
};
}
export type OwnedOrder = { order: number[]; root?: string } | { reason: OwnedTranscriptReason; shape: string[] };
/**
* Native journal writes can flush children before parents. Owned readers use
* causal order (the physical indices of the owned records); ordinary readers
* only recover membership, keeping physical order. `metas` are the records of
* this session's own file, in physical order.
*/
export function ownedCausalOrder(metas: readonly RecordMeta[], cwd: string): OwnedOrder {
const first = metas.find(m => m.conversation);
if (!first) return { order: [] };
const nodes = metas.filter(m => m.node);
// One map: each uuid's parent-session node, else the first record carrying it.
const byUuid = new Map<string, RecordMeta>();
for (const m of metas) if (m.uuid) {
const prior = byUuid.get(m.uuid);
if (prior?.node && m.node) return { reason: 'competing_root', shape: ['duplicate_uuid'] };
if (!prior || (m.node && !prior.node)) byUuid.set(m.uuid, m);
}
const byId = { get: (id: string) => { const m = byUuid.get(id); return m?.node ? m : undefined; } };
if (byId.get(first.uuid ?? '') !== first) return { reason: excludedOf(first), shape: [shapeOf(first)] };
// Anchor through the first observed conversation node, never an unrelated
// later root. An unflushed ancestor supplies no ownership yet.
const chain: RecordMeta[] = [first], seen = new Set<RecordMeta>(chain);
const shape = () => chain.map(shapeOf).reverse().slice(0, 32);
let genesis = false;
for (let at = first; ;) {
const id = at.parent;
if (!id) break;
const next = byId.get(id);
if (!next) {
const other = byUuid.get(id);
if (other) return { reason: excludedOf(other), shape: [shapeOf(other), ...shape()] };
// A resumed fork begins with a boundary whose logical parent stayed in the
// source session. It is a root only as this file's first record; mid-file
// it may be an unflushed ancestor, so it supplies no ownership yet.
if (!at.parentNull || at !== metas.find(m => m.uuid)) return { order: [] };
genesis = true;
break;
}
if (seen.has(next)) return { reason: 'cycle', shape: shape() };
chain.push(next); seen.add(next);
at = next;
}
const top = chain.toReversed(), root = top[0]!;
const turn = top.findIndex(n => n.message);
const children = new Map<string, RecordMeta[]>();
for (const node of nodes) if (node.parent) {
const list = children.get(node.parent) ?? []; list.push(node); children.set(node.parent, list);
}
// Uniqueness over every root candidate: a null-parent user, a SessionStart
// chain head carrying a conversation, and the resumed-genesis boundary.
const carriesTurn = (head: RecordMeta): boolean => {
for (const pending = [head]; pending.length;) for (const child of children.get(pending.pop()!.uuid!) ?? []) {
if (child.message) return true;
if (child.sessionStart) pending.push(child);
}
return false;
};
const candidates = new Set<RecordMeta>([root, ...nodes.filter(m => m.parentNull &&
(m.message ? m.role === 'user' : m.sessionStart)).filter(n => n.message || carriesTurn(n))]);
if (candidates.size > 1) return { reason: 'competing_root', shape: shape() };
for (const node of genesis ? [root] : top.slice(0, turn + 1)) if (!sameNativePath(node.cwd, cwd))
return { reason: sameRealPath(node.cwd, cwd) ? 'unrecognized_shape:cwd_spelling' : 'foreign_cwd', shape: shape() };
if (!genesis) {
const preamble = top.slice(0, turn).find(n => !n.sessionStart);
const odd = !root.parentNull ? `root_parent:${shapeOf(root)}`
: preamble ? `preamble:${shapeOf(preamble)}`
: top[turn]!.role !== 'user' ? `first_turn:${shapeOf(top[turn]!)}` : undefined;
if (odd) return { reason: `unrecognized_shape:${odd}`, shape: shape() };
}
// Stable topological traversal preserves physical order whenever two ready
// records have no parent dependency. No timestamp provides ordering credit.
const ready: number[] = [];
const offer = (value: number) => {
let i = ready.length; ready.push(value);
while (i > 0) { const p = (i - 1) >> 1; if (ready[p]! <= value) break;
ready[i] = ready[p]!; i = p; }
ready[i] = value;
};
const take = () => {
const result = ready[0]!, value = ready.pop()!;
if (ready.length) { let i = 0;
while (i * 2 + 1 < ready.length) { let c = i * 2 + 1;
if (c + 1 < ready.length && ready[c + 1]! < ready[c]!) c++;
if (ready[c]! >= value) break; ready[i] = ready[c]!; i = c; }
ready[i] = value;
}
return result;
};
const order: number[] = [];
offer(root.index);
while (ready.length) {
const node = metas[take()]!;
order.push(node.index);
for (const child of children.get(node.uuid!) ?? []) offer(child.index);
}
return { order, root: root.uuid };
}
+296
View File
@@ -0,0 +1,296 @@
/**
* The publication guard's bounded, append-tolerant read of one parent session
* journal (CEO-2). Pass one streams the bytes present at open, up to the last
* complete line, and keeps a small index per record: ownership metadata, byte
* range, and the record's public events with their content stripped. Ownership,
* ancestry and causal order are decided on that index. Pass two (the policy's
* `select`) reloads in full only the records the evaluation reads, under a named
* retained-data bound. Batch readers keep `readPlanCountTranscript`'s whole-file
* read and its cap.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { isUtf8 } from 'node:buffer';
import { object, recordEvents, recordMeta, ownedCausalOrder, nativePathSpelling, ownedNativePath, sameNativePath,
type ClaudeParentPublicEvent, type OwnedTranscriptReason, type RecordMeta } from './claude-journal-records';
import type { PlanCountTranscript } from './claude-public-transcript';
/** The largest single journal record the guard verifies; any record over it is `too_large` (docs/autoplan-guard-troubleshooting.md). */
export const OWNED_RECORD_MAX_BYTES = 32 * 1024 * 1024;
/** The most record bytes pass two may reload in full for one evaluation; over it is `oversized_invocation`. */
export const OWNED_RETAINED_MAX_BYTES = 32 * 1024 * 1024;
const lowered = (name: string, max: number) => {
const value = Number(process.env[name]);
return Number.isInteger(value) && value > 0 && value < max ? value : max;
};
/** Test seam: GSTACK_TRANSCRIPT_TEST_MAX_BYTES can only LOWER the record bound, never raise it. */
export const ownedRecordLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_MAX_BYTES', OWNED_RECORD_MAX_BYTES);
/** Test seam: GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES can only LOWER the retained bound, never raise it. */
export const ownedRetainedLimit = () => lowered('GSTACK_TRANSCRIPT_TEST_RETAINED_BYTES', OWNED_RETAINED_MAX_BYTES);
const CHUNK_BYTES = 1024 * 1024;
/** Parsed records are garbage once indexed; collecting every 32 MiB keeps a 120 MiB journal's peak RSS under budget (CEO-2). */
const COLLECT_EVERY_BYTES = 32 * 1024 * 1024;
/** The bytes [0, size) one read saw; a later read in the same hook invocation must see the same prefix. */
export interface JournalPrefix { size: number; sha256: string }
/** One owned record in causal order: where it is, and its events (stripped until pass two loads it). */
export interface IndexedRecord {
readonly offset: number;
readonly length: number;
readonly uuid?: string;
/** The first event order this record owns; pass two keeps it. */
readonly base: number;
readonly slots: number;
/** Tool ids whose result text is a JSON object, so a selector can load structured output without every result. */
readonly jsonResults?: readonly string[];
events: ClaudeParentPublicEvent[];
full: boolean;
}
/** How the hook bounds a read: what the index keeps of a tool input, and which records pass two loads. */
export interface OwnedReadPolicy {
/** The part of a tool_use input pass one keeps (small by construction); undefined keeps none. */
indexInput(name: string, input: Record<string, unknown>): Record<string, unknown> | undefined;
/** Load the records the evaluation reads, in any number of rounds; `load` enforces the retained bound. */
select(records: readonly IndexedRecord[], load: (records: readonly IndexedRecord[]) => void): void;
}
/** What a refused owned read can report without content: inputs to the hook's guidance and advisory. */
export interface OwnedTranscriptDiagnostic {
/** Claude Code version recorded in the journal's own records, when present. */
claudeVersion?: string;
/** Record types from the journal head down to its first turn; never content. */
rootShape: string[];
/** Parent assistant tool_use ids present in complete records. */
toolUseIds: string[];
/** sha256 of the bytes present at open, so a second read can prove the same prefix. */
sha256: string;
/** The journal ended at a record boundary when it was opened. */
complete: boolean;
}
export interface OwnedRead {
transcript: PlanCountTranscript;
events: ClaudeParentPublicEvent[];
diagnostic?: OwnedTranscriptDiagnostic;
claudeVersion?: string;
}
/** What one read measured, filled in for a caller that passes it (kept out of the result so equal journals read equal). */
export interface OwnedReadMeasure {
/** The prefix this read hashed; pass it as `prior` to the next read in the same hook invocation. */
prefix?: JournalPrefix;
/** too_large only: the oversized record's length in bytes. */
recordBytes?: number;
/** Bytes of the pass-one index (stripped events); set it to 0 to request the measurement. */
indexBytes?: number;
/** Bytes of the records pass two reloaded. */
retainedBytes?: number;
}
class OwnedReadError extends Error {
constructor(readonly reason: OwnedTranscriptReason, readonly bytes?: number) { super(reason); }
}
/** Strip, in place, content the index never keeps: tool input beyond the policy's part, result bodies, message text. */
function strip(events: ClaudeParentPublicEvent[], policy: OwnedReadPolicy): ClaudeParentPublicEvent[] {
for (const e of events) {
if (e.kind === 'use') e.input = e.input && policy.indexInput(e.name ?? '', e.input);
else if (e.kind === 'result') { e.content = undefined; e.file = undefined; }
else if (e.kind === 'message') e.text = '';
}
return events;
}
/** One record's index entry: its ownership metadata, byte range and (stripped) events. */
interface Entry extends RecordMeta {
offset: number;
length: number;
events?: ClaudeParentPublicEvent[];
slots: number;
/** The message content is an array (the record can make a journal ready). */
array: boolean;
jsonResults?: string[];
}
interface Scanned {
entries: Entry[];
toolUseIds: string[];
claudeVersion?: string;
prefix: JournalPrefix;
complete: boolean;
}
const resultText = (b: Record<string, any>): string => typeof b.content === 'string' ? b.content
: Array.isArray(b.content) && b.content.length === 1 && typeof b.content[0]?.text === 'string' ? b.content[0].text : '';
/** Pass one: stream [0, size) to the last complete line; per-record bound; prefix hash; content-free index. */
function scan(fd: number, size: number, sessionId: string, prior: JournalPrefix | undefined,
policy: OwnedReadPolicy | undefined): Scanned {
const limit = ownedRecordLimit(), hash = createHash('sha256'), filename = `${sessionId}.jsonl`;
const out: Scanned = { entries: [], toolUseIds: [], prefix: { size, sha256: '' }, complete: true };
const chunk = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, Math.max(size, 1)));
// The unfinished line carried into the next chunk; past the record bound only its length is kept.
let carry: Buffer[] = [], carryBytes = 0, carryOffset = 0;
const record = (line: string, offset: number, length: number) => {
if (!line.length || (line.charCodeAt(0) <= 32 && !line.trim())) return;
let value: unknown;
try { value = JSON.parse(line); } catch { throw new OwnedReadError('malformed'); }
if (!object(value) || value.sessionId !== sessionId || filename !== `${value.sessionId}.jsonl`) return;
const entry = recordMeta(value, out.entries.length) as Entry;
entry.offset = offset; entry.length = length; entry.slots = 0; entry.array = false;
out.entries.push(entry);
if (!entry.agent && !entry.sidechain) {
out.claudeVersion ??= entry.version;
if (entry.role === 'assistant' && Array.isArray(value.message.content)) for (const block of value.message.content)
if (object(block) && block.type === 'tool_use' && typeof block.id === 'string') out.toolUseIds.push(block.id);
}
if (!entry.message) return;
const full = recordEvents(value, true, entry.timestampValid);
entry.events = policy ? strip(full.events, policy) : full.events;
entry.slots = full.slots;
entry.array = Array.isArray(value.message.content);
if (entry.array) for (const b of value.message.content)
if (object(b) && b.type === 'tool_result' && typeof b.tool_use_id === 'string' && /^\s*\{/.test(resultText(b)))
(entry.jsonResults ??= []).push(b.tool_use_id);
};
const lines = (region: Buffer, start: number) => {
if (!isUtf8(region)) throw new OwnedReadError('malformed');
const text = region.toString('utf8'), ascii = text.length === region.length;
let offset = start;
for (const line of text.split('\n')) {
const length = ascii ? line.length : Buffer.byteLength(line);
if (length > limit) throw new OwnedReadError('too_large', length);
record(line, offset, length);
offset += length + 1;
}
};
for (let pos = 0; pos < size;) {
const got = fs.readSync(fd, chunk, 0, Math.min(chunk.length, size - pos), pos);
if (got <= 0) throw new OwnedReadError('identity');
const view = chunk.subarray(0, got);
if (prior && prior.size > 0 && pos < prior.size && pos + got >= prior.size) {
hash.update(view.subarray(0, prior.size - pos));
if (hash.copy().digest('hex') !== prior.sha256) throw new OwnedReadError('rewritten');
hash.update(view.subarray(prior.size - pos));
} else hash.update(view);
const last = view.lastIndexOf(10);
if (last >= 0) {
const first = view.indexOf(10);
if (carryBytes + first > limit) throw new OwnedReadError('too_large', carryBytes + first);
// The carried line joins its end in a small copy; the rest of the chunk decodes in place.
if (carry.length) lines(Buffer.concat([...carry, view.subarray(0, first)]), carryOffset);
if (carry.length ? last > first : true) lines(view.subarray(carry.length ? first + 1 : 0, last), carry.length ? pos + first + 1 : pos);
carry = []; carryBytes = 0; carryOffset = pos + last + 1;
}
const rest = view.subarray(last + 1);
// An oversized unfinished record keeps counting without retaining its bytes.
if (rest.length && carryBytes + rest.length <= limit) carry.push(Buffer.from(rest));
carryBytes += rest.length;
pos += got;
if (pos % COLLECT_EVERY_BYTES < got && typeof Bun !== 'undefined') Bun.gc(true);
}
if (carryBytes > limit) throw new OwnedReadError('too_large', carryBytes);
// A record still being written (no newline yet) is not evidence and is not read.
out.complete = carryBytes === 0;
out.prefix.sha256 = hash.digest('hex');
return out;
}
/** Ownership on the index, for each accepted owner directory; the first ready owner wins. */
function owned(scanned: Scanned, owners: readonly string[]) {
const results = owners.map(cwd => {
const causal = ownedCausalOrder(scanned.entries, cwd);
if ('reason' in causal) return { status: causal.reason === 'competing_root' || causal.reason === 'cycle' ? 'error' as const : 'missing' as const,
reason: causal.reason, shape: causal.shape };
return { status: causal.order.some(i => scanned.entries[i]!.array) ? 'ready' as const : 'missing' as const,
order: causal.order, reason: undefined, shape: [] as string[] };
});
return results.find(r => r.status === 'ready') ?? results.find(r => r.reason !== 'foreign_cwd') ?? results[0]!;
}
/**
* Read exactly the native hook's parent file; never scan another session.
* Appends after open never fail the read; the same device and inode and no
* shrink are required. Without a policy every owned record is kept in full.
*/
export function readOwnedClaudePublicTranscript(file: string, owners: string | readonly string[], sessionId: string,
options: { prior?: JournalPrefix; policy?: OwnedReadPolicy; measure?: OwnedReadMeasure } = {}): OwnedRead {
const cwds = typeof owners === 'string' ? [owners] : owners, measure = options.measure ?? {};
let fd: number | undefined, scanned: Scanned | undefined;
try {
if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !cwds.length || !cwds.every(cwd => path.isAbsolute(cwd)) || !ownedNativePath(file) ||
path.basename(nativePathSpelling(file)) !== `${sessionId}.jsonl`) throw new OwnedReadError('identity');
file = nativePathSpelling(file);
const project = path.dirname(file), projects = path.dirname(project), config = path.dirname(projects);
if (path.basename(projects) !== 'projects' ||
[config, projects, project].some(dir => !fs.lstatSync(dir).isDirectory() || !sameNativePath(fs.realpathSync(dir), dir)))
throw new OwnedReadError('identity');
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile()) throw new OwnedReadError('identity');
const size = Number(before.size);
if (options.prior && size < options.prior.size) throw new OwnedReadError('identity');
scanned = scan(fd, size, sessionId, options.prior, options.policy);
measure.prefix = scanned.prefix;
const after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true });
// Claude only appends: a different file or fewer bytes than at open is not this journal.
if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino ||
after.size < before.size || current.size < before.size) throw new OwnedReadError('identity');
const ownership = owned(scanned, cwds);
if (ownership.reason || ownership.status !== 'ready')
return { transcript: { status: ownership.status, calls: [], assistantMessages: [], ...(ownership.reason ? { reason: ownership.reason } : {}) },
events: [], diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}), rootShape: ownership.shape,
toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } };
const records: IndexedRecord[] = [];
let base = 0, claudeVersion: string | undefined;
for (const i of ownership.order!) {
const entry = scanned.entries[i]!;
if (!entry.events) continue;
claudeVersion = entry.version ?? claudeVersion;
for (const e of entry.events) e.order += base;
records.push({ offset: entry.offset, length: entry.length, uuid: entry.uuid, base, slots: entry.slots,
jsonResults: entry.jsonResults, events: entry.events, full: !options.policy });
base += entry.slots;
}
// Measured only on request (tests, the benchmark): serializing the index costs as much as building it.
if (measure.indexBytes !== undefined) measure.indexBytes = records.reduce((sum, r) => sum + JSON.stringify(r.events).length + 64, 0);
if (options.policy) {
const reader = fd, limit = ownedRetainedLimit();
let retained = 0;
options.policy.select(records, wanted => {
for (const r of wanted) {
if (r.full) continue;
retained += r.length; measure.retainedBytes = retained;
if (retained > limit) throw new OwnedReadError('oversized_invocation', retained);
const bytes = Buffer.alloc(r.length);
if (fs.readSync(reader, bytes, 0, r.length, r.offset) !== r.length || !isUtf8(bytes)) throw new OwnedReadError('rewritten');
let value: any;
try { value = JSON.parse(bytes.toString('utf8')); } catch { throw new OwnedReadError('rewritten'); }
// Pass two must find the record pass one indexed at the same bytes.
if (!object(value) || value.uuid !== r.uuid || !object(value.message)) throw new OwnedReadError('rewritten');
const full = recordEvents(value, true);
if (full.slots !== r.slots || full.events.length !== r.events.length) throw new OwnedReadError('rewritten');
for (const e of full.events) e.order += r.base;
r.events = full.events;
r.full = true;
}
});
}
return { transcript: { status: 'ready', calls: [], assistantMessages: [] }, events: records.flatMap(r => r.events),
...(claudeVersion ? { claudeVersion } : {}) };
} catch (error) {
if (error instanceof OwnedReadError && error.reason === 'too_large') measure.recordBytes = error.bytes;
// A journal Claude has not created yet is unflushed, not an identity failure.
const reason = error instanceof OwnedReadError ? error.reason :
(error as NodeJS.ErrnoException)?.code === 'ENOENT' ? undefined : 'identity';
return { transcript: { status: 'error', calls: [], assistantMessages: [], ...(reason ? { reason } : {}),
error: `Owned native public transcript is unavailable (${reason ?? 'not created yet'})` }, events: [],
...(scanned ? { diagnostic: { ...(scanned.claudeVersion ? { claudeVersion: scanned.claudeVersion } : {}),
rootShape: [], toolUseIds: scanned.toolUseIds, sha256: scanned.prefix.sha256, complete: scanned.complete } } : {}) };
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
+28 -421
View File
@@ -1,7 +1,13 @@
/** Lossless, read-only question metadata from one isolated Claude fixture. */
import * as fs from 'node:fs';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { object, validTimestamp, nativeUuid, recordEvents, recordMeta, ownedCausalOrder, sameNativePath,
type NativePublicToolEvent, type OwnedTranscriptReason } from './claude-journal-records';
export { nativePathSpelling, ownedNativePath, sameNativePath, type NativePublicToolEvent, type ClaudeParentPublicEvent,
type OwnedTranscriptReason } from './claude-journal-records';
export { readOwnedClaudePublicTranscript, OWNED_RECORD_MAX_BYTES, OWNED_RETAINED_MAX_BYTES, ownedRecordLimit, ownedRetainedLimit,
type OwnedTranscriptDiagnostic, type OwnedRead } from './claude-owned-journal';
export interface NativePlanQuestion {
header: string;
@@ -22,24 +28,6 @@ export interface NativePlanQuestionCall {
answeredAt?: string;
}
/** Optional public tool projection for the Autoplan delivery audit; never thinking. */
export interface NativePublicToolEvent {
sessionId: string;
timestamp: string;
toolUseId: string;
kind: 'use' | 'result';
name?: string;
/** Exact native message/request identity, used only for owned queued tools. */
messageId?: string;
requestId?: string;
input?: Record<string, unknown>;
content?: unknown;
file?: unknown;
isError?: boolean;
/** Owned reads only: Claude Code launched this Agent in the background (its result is a launch receipt). */
async?: boolean;
}
export interface PlanCountTranscript {
status: 'missing' | 'ready' | 'error';
calls: NativePlanQuestionCall[];
@@ -51,33 +39,13 @@ export interface PlanCountTranscript {
reason?: OwnedTranscriptReason;
}
/** Hook-only verified causal order (physical order for independent ready records). The existing fixture projection remains unchanged. */
export type ClaudeParentPublicEvent = (NativePublicToolEvent | {
kind: 'message'; sessionId: string; timestamp: string; text: string;
} | {
kind: 'end_turn' | 'user_turn'; sessionId: string; timestamp: string; autoplan?: boolean;
} | {
/** Claude Code's completion notice for a background Agent (origin kind task-notification). */
kind: 'task_notification'; sessionId: string; timestamp: string; notifiedToolUseId: string;
}) & { order: number; messageId?: string; requestId?: string };
interface OwnedSnapshot {
file: string;
text: string;
events: ClaudeParentPublicEvent[];
/** Record types (never content) from the journal head to the first turn, set on refusal. */
rootShape?: string[];
/** The newest owned record's Claude Code version (the CLI writing now), when present. */
claudeVersion?: string;
}
/** A rejected/refused call needs an actual later answer, not unrelated progress. */
export function unresolvedPlanQuestionCalls(calls: NativePlanQuestionCall[]): NativePlanQuestionCall[] {
return calls.filter((call, index) => call.failed && !call.questions.every(q =>
calls.slice(index + 1).some(later => later.answered && later.answers?.[q.question])));
}
/** The most journal bytes the guard reads; a longer session reports `too_large`, never `identity` (#3050). */
/** The most journal bytes a batch read takes (the guard's own reader bounds each record instead, CEO-2); over it is `too_large` (#3050). */
export const OWNED_TRANSCRIPT_MAX_BYTES = 32 * 1024 * 1024;
/** Test seam: GSTACK_TRANSCRIPT_TEST_MAX_BYTES can only LOWER the cap, never raise it. */
export function transcriptReadLimit(): number {
@@ -90,76 +58,6 @@ class TranscriptTooLarge extends Error {
constructor(readonly bytes: number) { super(`transcript is ${mib(bytes)}, over the ${mib(transcriptReadLimit())} read limit`); }
}
const MAX_FILES = 64;
const object = (value: unknown): value is Record<string, any> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const validTimestamp = (value: unknown): value is string =>
typeof value === 'string' && Number.isFinite(Date.parse(value));
/** A record's own Claude Code version string, shape-checked; never other content. */
const claudeVersionOf = (record: Record<string, any>): string | undefined =>
typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version) ? record.version : undefined;
/** Read one length-delimited protobuf field, rejecting malformed/ambiguous input. */
function signatureField(bytes: Uint8Array | undefined, wanted: number): Uint8Array | undefined {
if (!bytes) return;
let cursor = 0;
let result: Uint8Array | undefined;
let seen = false;
const integer = () => {
let value = 0;
for (let shift = 0; shift < 70; shift += 7) {
if (cursor >= bytes.length) throw new Error('truncated signature');
const byte = bytes[cursor++]!;
value += (byte & 127) * 2 ** shift;
if (!Number.isSafeInteger(value)) throw new Error('signature integer overflow');
if (!(byte & 128)) return value;
}
throw new Error('overlong signature integer');
};
while (cursor < bytes.length) {
const key = integer();
const field = Math.floor(key / 8);
if (field < 1 || field > 0x1fffffff) throw new Error('invalid signature field');
if (field === wanted) {
if (seen) throw new Error('duplicate signature field');
seen = true;
}
switch (key % 8) {
case 0: integer(); break;
case 1: cursor += 8; break;
case 2: {
const length = integer();
if (length > bytes.length - cursor) throw new Error('truncated signature field');
if (field === wanted) result = bytes.subarray(cursor, cursor + length);
cursor += length;
break;
}
case 5: cursor += 4; break;
default: throw new Error('unsupported signature wire type');
}
if (cursor > bytes.length) throw new Error('truncated signature field');
}
return result;
}
/**
* Claude's public narration renderer classifies signature fields 2→1→8 as
* block_kind="narration": summaries of inter-tool prose, not private reasoning.
* Match that metadata only in this already-owned native transcript. This is
* classification, not cryptographic signature verification. Never read the
* thinking text of an untagged, unknown, malformed or legacy block.
*/
function publicNarrationText(block: Record<string, any>): string | undefined {
if (block.type !== 'thinking' || typeof block.signature !== 'string' ||
block.signature.length > 64 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(block.signature)) return;
try {
const bytes = Buffer.from(block.signature, 'base64');
const canonical = bytes.toString('base64');
if (block.signature !== canonical && block.signature !== canonical.replace(/=+$/, '')) return;
const tag = signatureField(signatureField(signatureField(bytes, 2), 1), 8);
if (!tag || Buffer.from(tag).toString('utf8') !== 'narration') return;
return typeof block.thinking === 'string' && block.thinking.trim() ? block.thinking : undefined;
} catch { return; }
}
function validQuestions(value: unknown): value is NativePlanQuestion[] {
return Array.isArray(value) && value.length > 0 && value.every(q =>
@@ -168,161 +66,6 @@ function validQuestions(value: unknown): value is NativePlanQuestion[] {
object(o) && typeof o.label === 'string' && o.label.trim()));
}
/**
* Windows spells one native path C:\, c:\, C:/ or Git Bash /c/ (hook env,
* journal and tool input disagree). Fold separators, the MSYS drive prefix and
* drive-letter case only. No resolve: `.`, `..` and doubled separators stay
* distinct, so a normalize check after the fold still rejects them. The path
* module is a parameter so win32 call sites are unit-testable on any host.
*/
export function nativePathSpelling(value: string, p: typeof path = path): string {
if (p.sep !== '\\') return value;
return value.replace(/^\/([A-Za-z])(?=\/|$)/, '$1:').replaceAll('/', '\\')
.replace(/^[a-z](?=:)/, drive => drive.toUpperCase());
}
/** Validation-then-compare: absolute, and its folded spelling is already normal. */
export function ownedNativePath(value: unknown, p: typeof path = path): value is string {
if (typeof value !== 'string') return false;
const folded = nativePathSpelling(value, p);
return p.isAbsolute(folded) && p.normalize(folded) === folded;
}
export function sameNativePath(a: unknown, b: unknown, p: typeof path = path): boolean {
return typeof a === 'string' && typeof b === 'string' && nativePathSpelling(a, p) === nativePathSpelling(b, p);
}
function sameRealPath(a: unknown, b: unknown): boolean {
try { return typeof a === 'string' && typeof b === 'string' && sameNativePath(fs.realpathSync(a), fs.realpathSync(b)); }
catch { return false; }
}
/**
* Why an owned journal supplied no owned lines (docs/autoplan-guard-troubleshooting.md).
* Positive identity conflicts are hard; `changing`, `identity` and `malformed`
* retry; only `unrecognized_shape:*` may degrade to an advisory.
*/
export type OwnedTranscriptReason = 'competing_root' | 'foreign_cwd' | 'sidechain' | 'agent' | 'cycle' | 'too_large' |
'changing' | 'identity' | 'malformed' | `unrecognized_shape:${string}`;
type OwnedLines = { lines: string[]; root?: string } | { reason: OwnedTranscriptReason; shape: string[] };
const nativeUuid = (value: unknown): value is string => typeof value === 'string' &&
/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value);
/** Record type names only, never content: `attachment:hook_success`, `system:compact_boundary`. */
const recordShape = (r: Record<string, any>): string => [r.type, r.subtype, object(r.attachment) ? r.attachment.type : undefined]
.filter(x => typeof x === 'string' && x).map(x => x.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40)).join(':') || 'untyped';
/** Claude writes each SessionStart hook result as a message-less attachment chained above the first user turn. */
const sessionStartRecord = (r: Record<string, any>): boolean => r.type === 'attachment' && r.message == null &&
object(r.attachment) && r.attachment.hookEvent === 'SessionStart' &&
typeof r.attachment.type === 'string' && /^hook_[a-z_]{1,48}$/.test(r.attachment.type);
/** Native journal writes can flush children before parents. Owned snapshots
* use causal order; ordinary readers only recover membership, keeping physical order. */
function ownedCausalLines(lines: string[], cwd: string, filename: string): OwnedLines {
const parsed = lines.flatMap((line, index) => {
if (!line.trim()) return [];
const record = JSON.parse(line);
return object(record) && filename === `${record.sessionId}.jsonl` ? [{ line, index, record }] : [];
});
type Node = typeof parsed[number];
const first = parsed.find(x => x.record.agentId == null && object(x.record.message) &&
['user', 'assistant'].includes(x.record.message.role));
if (!first) return { lines: [] };
const nodes = parsed.filter(({ record: r }) => r.agentId == null && r.isSidechain === false &&
typeof r.cwd === 'string' && path.isAbsolute(r.cwd) && nativeUuid(r.uuid) && validTimestamp(r.timestamp));
const byId = new Map<string, Node>();
for (const node of nodes) {
if (byId.has(node.record.uuid)) return { reason: 'competing_root', shape: ['duplicate_uuid'] };
byId.set(node.record.uuid, node);
}
const anyById = new Map<string, Record<string, any>>();
for (const { record } of parsed) if (nativeUuid(record.uuid) && !anyById.has(record.uuid)) anyById.set(record.uuid, record);
const excluded = (r: Record<string, any>): OwnedTranscriptReason => r.agentId != null ? 'agent' :
r.isSidechain !== false ? 'sidechain' : 'unrecognized_shape:record_metadata';
const parent = (r: Record<string, any>): string | undefined => nativeUuid(r.parentUuid) ? r.parentUuid :
r.parentUuid === null && r.type === 'system' && r.subtype === 'compact_boundary' &&
r.message == null && nativeUuid(r.logicalParentUuid) ? r.logicalParentUuid : undefined;
if (byId.get(first.record.uuid) !== first) return { reason: excluded(first.record), shape: [recordShape(first.record)] };
// Anchor through the first observed conversation node, never an unrelated
// later root. An unflushed ancestor supplies no ownership yet.
const chain: Node[] = [first], seen = new Set<Node>(chain);
const shape = () => chain.map(n => recordShape(n.record)).reverse().slice(0, 32);
let genesis = false;
for (let at = first; ;) {
const id = parent(at.record);
if (!id) break;
const next = byId.get(id);
if (!next) {
const other = anyById.get(id);
if (other) return { reason: excluded(other), shape: [recordShape(other), ...shape()] };
// A resumed fork begins with a boundary whose logical parent stayed in the
// source session. It is a root only as this file's first record; mid-file
// it may be an unflushed ancestor, so it supplies no ownership yet.
if (at.record.parentUuid !== null || at !== parsed.find(x => nativeUuid(x.record.uuid))) return { lines: [] };
genesis = true;
break;
}
if (seen.has(next)) return { reason: 'cycle', shape: shape() };
chain.push(next); seen.add(next);
at = next;
}
const top = chain.toReversed(), root = top[0]!;
const turn = top.findIndex(n => object(n.record.message));
const children = new Map<string, Node[]>();
for (const node of nodes) {
const id = parent(node.record);
if (id) { const list = children.get(id) ?? []; list.push(node); children.set(id, list); }
}
// Uniqueness over every root candidate: a null-parent user, a SessionStart
// chain head carrying a conversation, and the resumed-genesis boundary.
const carriesTurn = (head: Node): boolean => {
for (const pending = [head]; pending.length;) for (const child of children.get(pending.pop()!.record.uuid) ?? []) {
if (object(child.record.message)) return true;
if (sessionStartRecord(child.record)) pending.push(child);
}
return false;
};
const candidates = new Set<Node>([root, ...nodes.filter(({ record: r }) => r.parentUuid === null &&
(object(r.message) ? r.message.role === 'user' : sessionStartRecord(r))).filter(n =>
object(n.record.message) || carriesTurn(n))]);
if (candidates.size > 1) return { reason: 'competing_root', shape: shape() };
for (const node of genesis ? [root] : top.slice(0, turn + 1)) if (!sameNativePath(node.record.cwd, cwd))
return { reason: sameRealPath(node.record.cwd, cwd) ? 'unrecognized_shape:cwd_spelling' : 'foreign_cwd', shape: shape() };
if (!genesis) {
const preamble = top.slice(0, turn).find(n => !sessionStartRecord(n.record));
const odd = root.record.parentUuid !== null ? `root_parent:${recordShape(root.record)}`
: preamble ? `preamble:${recordShape(preamble.record)}`
: top[turn]!.record.message.role !== 'user' ? `first_turn:${recordShape(top[turn]!.record)}` : undefined;
if (odd) return { reason: `unrecognized_shape:${odd}`, shape: shape() };
}
// Stable topological traversal preserves physical order whenever two ready
// records have no parent dependency. No timestamp provides ordering credit.
const ready: number[] = [];
const offer = (value: number) => {
let i = ready.length; ready.push(value);
while (i > 0) { const p = (i - 1) >> 1; if (ready[p]! <= value) break;
ready[i] = ready[p]!; i = p; }
ready[i] = value;
};
const take = () => {
const result = ready[0]!, value = ready.pop()!;
if (ready.length) { let i = 0;
while (i * 2 + 1 < ready.length) { let c = i * 2 + 1;
if (c + 1 < ready.length && ready[c + 1]! < ready[c]!) c++;
if (ready[c]! >= value) break; ready[i] = ready[c]!; i = c; }
ready[i] = value;
}
return result;
};
const indexed = new Map(nodes.map(x => [x.index, x]));
const ordered: string[] = [];
offer(root.index);
while (ready.length) {
const node = indexed.get(take())!;
ordered.push(node.line);
for (const child of children.get(node.record.uuid) ?? []) offer(child.index);
}
return { lines: ordered, root: root.record.uuid };
}
/**
* Count callers consume each answered (sessionId, toolUseId) once, regardless
* of questions[].length. A batched tool call must never become N findings.
@@ -333,7 +76,6 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
onPublicToolEvent?: (event: NativePublicToolEvent) => void,
/** Optional exact parent journal, already validated by the owning native hook. */
ownedParentTranscript?: string,
ownedSnapshot?: OwnedSnapshot,
): PlanCountTranscript {
const calls = new Map<string, NativePlanQuestionCall>();
const assistantMessages: PlanCountTranscript['assistantMessages'] = [];
@@ -344,52 +86,43 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
const projects = path.join(configDir, 'projects');
try {
if (!fs.existsSync(projects)) return { status: 'missing', calls: [], assistantMessages: [] };
const dirs = ownedSnapshot ? [{ name: path.basename(path.dirname(ownedSnapshot.file)) }]
: fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory());
const dirs = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory());
if (dirs.length > MAX_FILES) throw new Error('too many project directories');
for (const dir of dirs) {
const project = path.join(projects, dir.name);
const entries = ownedSnapshot ? [{ name: path.basename(ownedSnapshot.file), isFile: () => true }]
: fs.readdirSync(project, { withFileTypes: true });
for (const entry of entries) {
for (const entry of fs.readdirSync(project, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
if (++files > MAX_FILES) throw new Error('too many transcript files');
const file = path.join(project, entry.name);
if (ownedParentTranscript !== undefined && file !== ownedParentTranscript) continue;
bytes += ownedSnapshot ? Buffer.byteLength(ownedSnapshot.text) : fs.statSync(file).size;
bytes += fs.statSync(file).size;
if (bytes > transcriptReadLimit()) throw new TranscriptTooLarge(bytes);
const text = ownedSnapshot?.text ?? fs.readFileSync(file, 'utf8');
let publicOrder = 0;
const text = fs.readFileSync(file, 'utf8');
// Native sessions retain their original journal after Bash changes cwd.
// Admit that continuation only through UUID ancestry rooted in this
// fixture's first parent user message; legacy records keep exact-cwd scoping.
let originSeen = false;
const ancestry = new Set<string>();
let causal: OwnedLines | undefined, causalMembership: Set<string> | undefined, ownedLines: string[] | undefined;
let causalMembership: Set<string> | undefined;
// Claude appends JSONL during rendering; an unfinished record is not
// evidence of a call or an answer until its newline has been written.
const completeLines = text.slice(0, text.lastIndexOf('\n') + 1).split('\n');
const recoveredMember = (id: string) => {
if (causalMembership === undefined) {
// Invalid strict ancestry adds no recovery; legacy traversal continues.
try { causal ??= ownedCausalLines(completeLines, cwd, entry.name); } catch { causal = { lines: [] }; }
causalMembership = new Set('lines' in causal ? causal.lines.map(line => JSON.parse(line).uuid) : []);
try {
const records = completeLines.flatMap(line => {
if (!line.trim()) return [];
const record = JSON.parse(line);
return object(record) && entry.name === `${record.sessionId}.jsonl` ? [record] : [];
});
const causal = ownedCausalOrder(records.map(recordMeta), cwd);
causalMembership = new Set('order' in causal ? causal.order.map(i => records[i]!.uuid) : []);
} catch { causalMembership = new Set(); }
}
return causalMembership.has(id);
};
if (ownedSnapshot) {
causal = ownedCausalLines(completeLines, cwd, entry.name);
if ('reason' in causal) {
ownedSnapshot.rootShape = causal.shape;
return { status: causal.reason === 'competing_root' || causal.reason === 'cycle' ? 'error' : 'missing',
calls: [], assistantMessages: [], reason: causal.reason };
}
// The verified root seeds ownership; message-less preamble and
// boundary roots supply no events of their own.
ownedLines = causal.lines;
if (causal.root) ancestry.add(causal.root);
}
for (const line of ownedLines ?? completeLines) {
for (const line of completeLines) {
if (!line.trim()) continue;
const record = JSON.parse(line);
if (!object(record) || typeof record.sessionId !== 'string' ||
@@ -403,8 +136,7 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
// A delayed metadata parent must not cut an already-rooted native
// session at its first cwd change. Recover membership lazily; do
// not discover a later root or reorder public uses and results.
(!ownedSnapshot && !sameNativePath(record.cwd, cwd) && ancestry.size > 0 &&
recoveredMember(record.uuid)));
(!sameNativePath(record.cwd, cwd) && ancestry.size > 0 && recoveredMember(record.uuid)));
if (!originSeen && object(record.message) && ['user', 'assistant'].includes(record.message.role)) {
originSeen = true;
// The first user prompt roots ownership itself, or through the
@@ -423,58 +155,15 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
record.message == null && nativeUuid(record.logicalParentUuid) &&
ancestry.has(record.logicalParentUuid) && !ancestry.has(record.uuid);
if (compactContinuation) ancestry.add(record.uuid);
if (ownedSnapshot && !ancestry.has(record.uuid)) continue;
if ((!sameNativePath(record.cwd, cwd) && !continuation) || record.isSidechain !== false || !object(record.message)) continue;
if (ownedSnapshot) ownedSnapshot.claudeVersion = claudeVersionOf(record) ?? ownedSnapshot.claudeVersion;
const notified = ownedSnapshot && record.message.role === 'user' && record.origin?.kind === 'task-notification' &&
typeof record.message.content === 'string' ? /<tool-use-id>([A-Za-z0-9_-]{1,160})<\/tool-use-id>/.exec(record.message.content)?.[1] : undefined;
if (notified) ownedSnapshot!.events.push({ kind: 'task_notification', sessionId: record.sessionId, timestamp: record.timestamp,
order: publicOrder++, notifiedToolUseId: notified });
if (ownedSnapshot && record.message.role === 'user' && record.origin?.kind === 'human' &&
record.isMeta !== true && nativeUuid(record.promptId) && typeof record.message.content === 'string') {
const autoplan = /^<command-message>autoplan<\/command-message>\n<command-name>\/autoplan<\/command-name>(?:\n<command-args>[\s\S]*<\/command-args>)?$/.test(record.message.content);
if (record.promptSource === 'typed' || autoplan) ownedSnapshot.events.push({ kind: 'user_turn',
sessionId: record.sessionId, timestamp: record.timestamp, order: publicOrder++, autoplan });
}
if (!Array.isArray(record.message.content)) continue;
matched = true;
for (const event of recordEvents(record, false).events) {
if (event.kind === 'message') assistantMessages.push({ sessionId: record.sessionId, text: event.text, timestamp: record.timestamp });
else if (event.kind === 'use' || event.kind === 'result') onPublicToolEvent?.(event);
}
for (const block of record.message.content) {
const order = publicOrder++;
if (!object(block)) continue;
const ordered = (event: NativePublicToolEvent | { kind: 'message'; sessionId: string; timestamp: string; text: string }) => {
if (ownedSnapshot) ownedSnapshot.events.push({ ...event, order,
...(typeof record.message.id === 'string' ? { messageId: record.message.id } : {}),
...(typeof record.requestId === 'string' ? { requestId: record.requestId } : {}) });
};
if (onPublicToolEvent && validTimestamp(record.timestamp)) {
if (record.message.role === 'assistant' && block.type === 'tool_use' &&
typeof block.id === 'string' && typeof block.name === 'string' && object(block.input)) {
const batch = typeof record.message.id === 'string' && /^msg_[A-Za-z0-9_-]{1,160}$/.test(record.message.id) &&
typeof record.requestId === 'string' && /^req_[A-Za-z0-9_-]{1,160}$/.test(record.requestId)
? { messageId: record.message.id, requestId: record.requestId } : {};
const event: NativePublicToolEvent = { sessionId: record.sessionId, timestamp: record.timestamp,
toolUseId: block.id, kind: 'use', name: block.name, input: block.input, ...batch };
onPublicToolEvent(event);
ordered(event);
} else if (record.message.role === 'user' && block.type === 'tool_result' &&
typeof block.tool_use_id === 'string') {
const event: NativePublicToolEvent = { sessionId: record.sessionId, timestamp: record.timestamp,
toolUseId: block.tool_use_id, kind: 'result', content: block.content,
file: record.toolUseResult?.file, isError: block.is_error === true,
...(ownedSnapshot && record.toolUseResult?.isAsync === true ? { async: true } : {}) };
onPublicToolEvent(event);
ordered(event);
}
}
if (record.message.role === 'assistant' && validTimestamp(record.timestamp)) {
const text = block.type === 'text' && typeof block.text === 'string' && block.text.trim()
? block.text : publicNarrationText(block);
if (text) {
assistantMessages.push({ sessionId: record.sessionId, text, timestamp: record.timestamp });
ordered({ kind: 'message', sessionId: record.sessionId, text, timestamp: record.timestamp });
}
}
if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'ExitPlanMode' &&
typeof block.id === 'string' && validTimestamp(record.timestamp)) {
const key = `${record.sessionId}:${block.id}`;
@@ -515,9 +204,6 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
}
}
}
if (ownedSnapshot && record.message.role === 'assistant' && record.message.stop_reason === 'end_turn')
ownedSnapshot.events.push({ kind: 'end_turn', sessionId: record.sessionId, timestamp: record.timestamp,
order: publicOrder++, ...(typeof record.message.id === 'string' ? { messageId: record.message.id } : {}) });
}
}
}
@@ -531,82 +217,3 @@ export function readPlanCountTranscript(configDir: string, cwd: string,
}
}
/** What a refused owned read can report without content: inputs to the hook's guidance and advisory. */
export interface OwnedTranscriptDiagnostic {
/** Claude Code version recorded in the journal's own records, when present. */
claudeVersion?: string;
/** Record types from the journal head down to its first turn; never content. */
rootShape: string[];
/** Parent assistant tool_use ids present in complete records. */
toolUseIds: string[];
/** sha256 of the exact bytes read, so two reads can prove a stable snapshot. */
sha256: string;
/** The journal ends at a record boundary (no partially written record). */
complete: boolean;
}
class OwnedReadError extends Error {
constructor(readonly reason: OwnedTranscriptReason) { super(reason); }
}
function diagnose(bytes: Buffer, sessionId: string, rootShape: string[] = []): OwnedTranscriptDiagnostic {
const text = bytes.toString('utf8'), toolUseIds: string[] = [];
let claudeVersion: string | undefined;
for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) {
let record: unknown;
try { record = JSON.parse(line); } catch { continue; }
if (!object(record) || record.sessionId !== sessionId || record.agentId != null || record.isSidechain !== false) continue;
claudeVersion ??= claudeVersionOf(record);
if (object(record.message) && record.message.role === 'assistant' && Array.isArray(record.message.content))
for (const block of record.message.content)
if (object(block) && block.type === 'tool_use' && typeof block.id === 'string') toolUseIds.push(block.id);
}
return { ...(claudeVersion ? { claudeVersion } : {}), rootShape, toolUseIds,
sha256: createHash('sha256').update(bytes).digest('hex'), complete: text.endsWith('\n') };
}
/** Read exactly the native hook's parent file; never scan another session. */
export function readOwnedClaudePublicTranscript(file: string, cwd: string, sessionId: string): {
transcript: PlanCountTranscript; events: ClaudeParentPublicEvent[]; diagnostic?: OwnedTranscriptDiagnostic; claudeVersion?: string;
} {
let fd: number | undefined, bytes: Buffer | undefined;
try {
if (!/^[A-Za-z0-9_-]{1,160}$/.test(sessionId) || !path.isAbsolute(cwd) || !ownedNativePath(file) ||
path.basename(nativePathSpelling(file)) !== `${sessionId}.jsonl`) throw new OwnedReadError('identity');
file = nativePathSpelling(file);
const project = path.dirname(file), projects = path.dirname(project), config = path.dirname(projects);
if (path.basename(projects) !== 'projects' ||
[config, projects, project].some(dir => !fs.lstatSync(dir).isDirectory() || !sameNativePath(fs.realpathSync(dir), dir)))
throw new OwnedReadError('identity');
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
const before = fs.fstatSync(fd, { bigint: true });
if (!before.isFile()) throw new OwnedReadError('identity');
// A journal only grows, so an oversize one can never pass on retry (#3050).
if (before.size > BigInt(transcriptReadLimit())) throw new OwnedReadError('too_large');
bytes = fs.readFileSync(fd);
const after = fs.fstatSync(fd, { bigint: true }), current = fs.lstatSync(file, { bigint: true });
if (!current.isFile() || before.dev !== current.dev || before.ino !== current.ino) throw new OwnedReadError('identity');
// Racing Claude's own append is transient: the hook retries within its deadline.
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs ||
before.size !== current.size || before.mtimeNs !== current.mtimeNs ||
before.size !== BigInt(bytes.length)) throw new OwnedReadError('changing');
const text = bytes.toString('utf8');
if (!Buffer.from(text).equals(bytes)) throw new OwnedReadError('malformed');
const snapshot: OwnedSnapshot = { file, text, events: [] };
const transcript = readPlanCountTranscript(config, cwd, () => {}, file, snapshot);
if (snapshot.events.some(event => event.sessionId !== sessionId)) throw new OwnedReadError('identity');
if (transcript.status === 'error') transcript.reason ??= 'malformed';
if (transcript.status === 'ready') return { transcript, events: snapshot.events,
...(snapshot.claudeVersion ? { claudeVersion: snapshot.claudeVersion } : {}) };
return { transcript, events: [], diagnostic: diagnose(bytes, sessionId, snapshot.rootShape) };
} catch (error) {
// A journal Claude has not created yet is unflushed, not an identity failure.
const reason = error instanceof OwnedReadError ? error.reason :
(error as NodeJS.ErrnoException)?.code === 'ENOENT' ? undefined : 'identity';
return { transcript: { status: 'error', calls: [], assistantMessages: [], ...(reason ? { reason } : {}),
error: `Owned native public transcript is unavailable (${reason ?? 'not created yet'})` }, events: [],
...(bytes ? { diagnostic: diagnose(bytes, sessionId) } : {}) };
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}