mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* fix(land-and-deploy): never merge over red, pending or missing CI (#2995) /land-and-deploy gated only on required checks, so a repo with none (every GitHub Free private repo, any repo without branch protection) skipped the CI wait and could merge over red CI. The verdict now comes from bin/gstack-ci-gate, a tested helper bound to the head SHA: all checks by default, cancel/unknown buckets fail, NO_CHECKS needs explicit per-head approval, and both merge commands (including --auto) run only after a PASS for the same SHA they pin with --match-head-commit. Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(slug): give nested-group projects their own state bucket and verify checkpoint identity (#3003, #2901) gstack-slug kept only the last two remote path segments, so customer-a/product/repo and customer-b/product/repo shared one bucket and /context-restore could present another project's checkpoint. - One canonical-remote rule (bash + TS, golden vectors) used by all six resolvers: lowercased host, no userinfo/port/.git, Azure DevOps and Bitbucket Server normalized. 3+ segment remotes get <last-two>-<16 hex>; two-segment remotes (all of GitHub) are unchanged. - Versioned sticky cache (v2:) keeps cache hits git-free. - context-save stamps the credential-free remote and root; context-restore only treats this project's checkpoints as latest, reports foreign/unverified ones first, and treats a different root with the same remote (Conductor handoff) as information. - gstack-slug --adopt-legacy copies only files the user confirms. - question-preference-hook reads the same versioned cache, so never-ask preferences fire; unknown identity asks instead of auto-deciding. * fix(gbrain): brain context loads, managed sync works, memory sync stops duplicating (#2883, #2985, #2922) - brain-context-load called 'gbrain list_pages' (an MCP tool name), so every skill's brain context was 0 bytes. It now uses 'gbrain list' with gbrain's real filter flags, and prints one line when queries fail. - The code-stage sync always passes --no-pull (managed gbrain >= 0.51 refuses without it); a gbrain that rejects the flag fails with an upgrade message and is never retried without it. stderr is streamed with a bounded tail and quoted in failure summaries. - /sync-gbrain gains --sources (and GSTACK_MEMORY_INGEST_SOURCES); by default it skips types already synced through a federated curated source, so curated pages stop being re-duplicated. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> * fix(codex,cso): clean Codex reviews stop failing; semgrep and trivy SARIF results import (#2769, #3011) - Codex review gate is PASS / FAIL / UNVERIFIED. Untagged output (what a clean 'codex review --base' prints) is UNVERIFIED, not FAIL, and never a false PASS; P2/P3-only output passes. - import-sarif resolves semgrep's undeclared %SRCROOT% and trivy's ROOTPATH (including git-URL targets) through explicit, captured mappings; declared bases must sit inside the source root or the run's checkout, URIs are percent-decoded, and rejected results are counted with rule, base and path instead of silently dropped. * fix: version strings are not IPs, failed update checks are not 'up to date', private security reporting (#2856, #2786, #2984) - The pre-push redact hook exempts a root VERSION file that is only a version string and the version token in CHANGELOG release headings; the engine allowlist is untouched. - gstack-update-check caches an unreadable remote as CHECK_FAILED with a short TTL instead of UP_TO_DATE; /gstack-upgrade never reports 'latest' after a failed check. - SECURITY.md points reporters at GitHub private vulnerability reporting. - pr-title-sync (pull_request_target, write token) pins actions/checkout to a reviewed SHA. * fix(autoplan): publication guard accepts real Claude Code journals (#3007, #3009, #2968, #2977, #2986, #3006) The guard required a session journal to start with a user message, but Claude Code starts it with SessionStart hook attachments (and a forked compacted session starts with a compact_boundary), so every phase was denied for anyone with a SessionStart hook. Windows path spellings (C:/, /c/, drive case) were rejected too. - Reader accepts the captured startup, compact, clear and fork shapes (claude 2.1.284 fixture), computes root uniqueness over every candidate, and returns typed reason codes instead of one string. - Hard denials (competing root, foreign cwd, sidechain, agent, cycle, malformed, identity) say what happened and how to run the reviews by hand; unflushed journals still retry. - A well-formed, stable journal whose root shape is unrecognized now warns ('phase publication was NOT verified') and continues, without overriding Claude Code's own permission check; no bypass switch. - Windows paths fold before validation, so '..' is still rejected. - Periodic drift canary runs the reader on fresh journals from the pinned claude. Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com> * v1.91.13.0: severe bug fix wave release notes, stale gbrain verbs in docs * test: seed the versioned slug cache in the memory-injection deny case (#2901) The preference hook now resolves the project bucket from gstack-slug's v2 slug-cache entry instead of basename(cwd); this fixture still wrote preferences under the basename bucket without the entry the skill preamble always writes first. * fix: full-suite follow-ups for the fix wave - gstack-slug's --help heredoc is over 512B, so it gets the BASH_COMPAT=50 pipe-deadlock guard like the other bins. - Refresh the codex/factory ship goldens for the plan-gates slug change. - The periodic lane gains the autoplan journal drift canary (107 paid files); raise the eval-slices cap to 25 so every slice still starts at once until its recorded duration lets the planner pack it. * fix(plan-eng-review): clearer Step 0 sequencing, write routes and section outputs The workflow judge scored plan-eng-review's instructions at actionability 3.67 (bar 4) on text unchanged from main. Resolve the ambiguities it named together, without changing any gate or output: Step 0 names the Read's order and keeps one STOP; the pending-call, read-only, forbidden-write and failed-save rules become one Write routes block; header/label limits and grid checks are concrete; Performance gets a checklist; each section has a stated output shape; Lake Score is defined. Same total length (parity cap). --------- Co-authored-by: harshitgavita-07 <harshit.gavita@gmail.com> Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Charles M Berg <77517285+CharlesBerg85@users.noreply.github.com> Co-authored-by: laurenbdaniels-coder <264343335+laurenbdaniels-coder@users.noreply.github.com> Co-authored-by: Jordan Mayer <176445995+maydaycyber@users.noreply.github.com> Co-authored-by: elmesery-dev <elmesery.dev@gmail.com>
582 lines
33 KiB
TypeScript
582 lines
33 KiB
TypeScript
/** 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';
|
|
|
|
export interface NativePlanQuestion {
|
|
header: string;
|
|
question: string;
|
|
options: Array<{ label: string; description?: string }>;
|
|
multiSelect?: boolean;
|
|
}
|
|
|
|
export interface NativePlanQuestionCall {
|
|
sessionId: string;
|
|
toolUseId: string;
|
|
questions: NativePlanQuestion[];
|
|
answered: boolean;
|
|
failed?: boolean;
|
|
failure?: string;
|
|
answers?: Record<string, string>;
|
|
unansweredQuestionIndices?: number[];
|
|
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;
|
|
}
|
|
|
|
export interface PlanCountTranscript {
|
|
status: 'missing' | 'ready' | 'error';
|
|
calls: NativePlanQuestionCall[];
|
|
assistantMessages: Array<{ sessionId: string; text: string; timestamp: string }>;
|
|
/** Actual native plan-mode approval requests; pending is the UI gate, never an AUQ. */
|
|
planReadyRequests?: Array<{ sessionId: string; toolUseId: string; timestamp: string; failed: boolean; source?: 'pre_tool_use' }>;
|
|
error?: string;
|
|
/** Owned reads only: why the exact parent journal supplied no owned lines. */
|
|
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;
|
|
}) & { 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[];
|
|
}
|
|
|
|
/** 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])));
|
|
}
|
|
|
|
const MAX_BYTES = 32 * 1024 * 1024;
|
|
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));
|
|
|
|
/** 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 =>
|
|
object(q) && typeof q.header === 'string' && typeof q.question === 'string' && q.question.trim() &&
|
|
Array.isArray(q.options) && q.options.length >= 2 && q.options.every((o: unknown) =>
|
|
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' |
|
|
'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.
|
|
* Partial final lines remain pending; missing/foreign/sidechain records add
|
|
* no coverage. Traversal stays inside the owned config's projects directory.
|
|
*/
|
|
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'] = [];
|
|
const planReadyRequests = new Map<string, NonNullable<PlanCountTranscript['planReadyRequests']>[number]>();
|
|
let matched = false;
|
|
let bytes = 0;
|
|
let files = 0;
|
|
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());
|
|
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) {
|
|
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;
|
|
if (bytes > MAX_BYTES) throw new Error('transcript exceeds 32 MiB read limit');
|
|
const text = ownedSnapshot?.text ?? fs.readFileSync(file, 'utf8');
|
|
let publicOrder = 0;
|
|
// 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;
|
|
// 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) : []);
|
|
}
|
|
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) {
|
|
if (!line.trim()) continue;
|
|
const record = JSON.parse(line);
|
|
if (!object(record) || typeof record.sessionId !== 'string' ||
|
|
entry.name !== `${record.sessionId}.jsonl` ||
|
|
(ownedParentTranscript !== undefined && record.agentId != null)) continue;
|
|
const parentMetadata = record.isSidechain === false && record.agentId == null &&
|
|
typeof record.cwd === 'string' && path.isAbsolute(record.cwd) &&
|
|
nativeUuid(record.uuid) && validTimestamp(record.timestamp);
|
|
const continuation = parentMetadata && nativeUuid(record.parentUuid) &&
|
|
!ancestry.has(record.uuid) && (ancestry.has(record.parentUuid) ||
|
|
// 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)));
|
|
if (!originSeen && object(record.message) && ['user', 'assistant'].includes(record.message.role)) {
|
|
originSeen = true;
|
|
// The first user prompt roots ownership itself, or through the
|
|
// verified causal root above it (SessionStart preamble, resumed
|
|
// boundary). Children can flush before parents, so physical order
|
|
// never decides that ancestry.
|
|
if (parentMetadata && sameNativePath(record.cwd, cwd) && record.message.role === 'user' &&
|
|
(record.parentUuid === null || recoveredMember(record.uuid))) ancestry.add(record.uuid);
|
|
}
|
|
if (continuation) ancestry.add(record.uuid);
|
|
// Native compaction resets parentUuid but links its prior owned
|
|
// UUID ancestry through logicalParentUuid. Summary text does
|
|
// not establish ownership, and an arbitrary reset cannot seed a root.
|
|
const compactContinuation = parentMetadata && record.type === 'system' &&
|
|
record.subtype === 'compact_boundary' && record.parentUuid === null &&
|
|
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 && 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 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 };
|
|
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}`;
|
|
if (!planReadyRequests.has(key)) planReadyRequests.set(key, { sessionId: record.sessionId,
|
|
toolUseId: block.id, timestamp: record.timestamp, failed: false });
|
|
}
|
|
if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'AskUserQuestion' &&
|
|
typeof block.id === 'string' && object(block.input) && validQuestions(block.input.questions)) {
|
|
const key = `${record.sessionId}:${block.id}`;
|
|
const prior = calls.get(key);
|
|
if (prior && JSON.stringify(prior.questions) !== JSON.stringify(block.input.questions)) {
|
|
throw new Error('conflicting question metadata for one tool call');
|
|
}
|
|
if (!prior) calls.set(key, { sessionId: record.sessionId, toolUseId: block.id,
|
|
questions: block.input.questions, answered: false, failed: false });
|
|
} else if (record.message.role === 'user' && block.type === 'tool_result' &&
|
|
typeof block.tool_use_id === 'string') {
|
|
const ready = planReadyRequests.get(`${record.sessionId}:${block.tool_use_id}`);
|
|
if (ready && block.is_error === true) ready.failed = true;
|
|
const call = calls.get(`${record.sessionId}:${block.tool_use_id}`);
|
|
const answers = record.toolUseResult?.answers;
|
|
const validAnswers = call && object(answers) ? Object.fromEntries(call.questions
|
|
.filter(q => typeof answers[q.question] === 'string' && answers[q.question].trim())
|
|
.map(q => [q.question, answers[q.question]])) : {};
|
|
if (call && block.is_error !== true && Object.keys(validAnswers).length > 0) {
|
|
// The CLI allows submitting a multi-question packet with
|
|
// unanswered tabs. This completes ONE call, not N questions.
|
|
call.answered = true;
|
|
call.failed = false;
|
|
delete call.failure;
|
|
call.answers = validAnswers;
|
|
call.unansweredQuestionIndices = call.questions.flatMap((q, i) => q.question in validAnswers ? [] : [i]);
|
|
call.answeredAt = validTimestamp(record.timestamp) ? record.timestamp : undefined;
|
|
} else if (call) {
|
|
if (call.answered) throw new Error('conflicting successful and failed results for one question call');
|
|
call.failed = true;
|
|
call.failure = block.is_error === true ? 'Native question tool returned is_error' : 'Native question returned no matching nonempty answers';
|
|
}
|
|
}
|
|
}
|
|
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 } : {}) });
|
|
}
|
|
}
|
|
}
|
|
return { status: matched ? 'ready' : 'missing', calls: [...calls.values()], assistantMessages,
|
|
...(planReadyRequests.size ? { planReadyRequests: [...planReadyRequests.values()] } : {}) };
|
|
} catch (error) {
|
|
// A failed read cannot silently turn an incomplete transcript into a
|
|
// complete review. Keep the diagnostic explicit and return no coverage.
|
|
return { status: 'error', calls: [], assistantMessages: [], error: `Claude question transcript: ${String(error)}` };
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
if (!claudeVersion && typeof record.version === 'string' && /^\d+\.\d+\.\d+[0-9A-Za-z.+-]{0,24}$/.test(record.version))
|
|
claudeVersion = record.version;
|
|
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;
|
|
} {
|
|
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() || before.size > BigInt(MAX_BYTES)) throw new OwnedReadError('identity');
|
|
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 };
|
|
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);
|
|
}
|
|
}
|