Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,26 @@ original rejection and retry policy. Transport errors and provider token-budget
rejections occur before this boundary and are not captured. No provider call,
retry reset, candidate approval, or export is triggered by capture or replay.

### Local unknown Claude category capture

The Anthropic user-session adapter accepts an optional
`localClaudeCategoryCaptureParent` for explicit diagnostic sessions. Capture is
disabled by default, has no CLI or renderer control, and requires a
caller-selected parent directory that already exists. When a structured Claude
error contains an unrecognized category-shaped `subtype` or `terminal_reason`,
the adapter writes only those exact strings to a new `categories.json` file in
an unpredictable private subdirectory. Each value is limited to 128 UTF-8
bytes; an oversized value prevents any partial capture. On POSIX, the directory
uses mode `0700` and the file uses `0600`.

The capture excludes stop reasons, provider prose, prompts and outputs, errors,
session and usage data, model content, paths, URLs, credentials, and environment
values. Neither the capture path nor captured strings enter the provider error
or run history. A fixed capture success/failure diagnostic is appended without
changing the original error code, message, status, retryability, or failure
stage. Capture never triggers a provider call or changes retry behavior; the
caller owns retention and deletion of the local file.

### Author adjudication and revision trace boundary

`packages/schemas` also owns the strict, versioned author-adjudication plan and
Expand Down
10 changes: 10 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,15 @@ stop-reason categories. Missing, malformed, or future values receive fixed
fallback diagnostics, and free-form provider errors remain excluded. This does
not reinterpret #378 or authorize another live attempt.

Issue #393 adds opt-in, local-only capture for unrecognized Claude result
subtype and terminal-reason category strings. Capture is disabled by default
and writes only bounded category-shaped values to a new `0600` JSON file inside
an unpredictable `0700` subdirectory of a caller-selected existing parent.
Stop reasons, provider prose, prompts, outputs, paths, credentials, and other
private material remain excluded; durable diagnostics record only fixed
capture-saved or capture-failed codes. This provider-free diagnostic does not
reinterpret #384 or authorize another live attempt.

The twenty-second bounded observation under #382 used revision
`349388de4820eca30543492d8ad1266199cdda3e` after explicit provider-transmission
authorization. Both 20-second authentication probes passed, but three
Expand Down Expand Up @@ -877,6 +886,7 @@ issues retain implementation chronology.

| Date | Decision | Product implication |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-14 | Added opt-in local capture for unknown Claude categories under #393. | Explicit diagnostic sessions can preserve only bounded, category-shaped unknown result subtype and terminal-reason strings in a private caller-owned file. Capture is disabled by default, durable history remains content-free, provider behavior is unchanged, and no live attempt is authorized. |
| 2026-09-13 | Recorded #384 as an indeterminate twenty-third matched-backend observation. | After explicit authorization, both 20-second authentication probes passed, but one Anthropic author attempt failed non-retryably with `unknown` after 351,508 ms. Fixed diagnostics classify only result subtype, terminal reason, and stop reason; no artifact or review occurred, and authorization is exhausted. This adds no product-quality evidence; #75/#250 remain blocked. |
| 2026-09-13 | Recorded #382 as an indeterminate twenty-second matched-backend observation. | After explicit authorization, both 20-second auth probes passed, but three Anthropic author attempts failed with generic `invalid-response`; the first two were retryable and the final attempt was non-retryable with factual-invariant rejection. No artifact or review occurred; authorization is exhausted and #75/#250 remain blocked. |
| 2026-09-13 | Added content-free Claude result-error attribution under #380. | Statusless structured failures retain their existing safe classification and retryability while allowlisted result, terminal, and stop categories make later failures diagnosable without provider prose or private material. This does not reinterpret #378 or authorize another live attempt. |
Expand Down
126 changes: 126 additions & 0 deletions packages/providers/src/claude-category-capture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

import {
captureUnknownClaudeCategories,
maximumLocalClaudeSubtypeBytes,
maximumLocalClaudeTerminalReasonBytes,
} from "./claude-category-capture.js";

const knownSubtypes = new Set(["error_max_turns", "success"]);
const knownTerminalReasons = new Set(["completed", "model_error"]);

async function withCaptureParent<T>(action: (parent: string) => Promise<T>): Promise<T> {
const parent = await mkdtemp(join(tmpdir(), "draft-loop-claude-capture-test-"));
try {
return await action(parent);
} finally {
await rm(parent, { recursive: true, force: true });
}
}

describe("captureUnknownClaudeCategories", () => {
it("writes only exact unknown categories into a private new JSON file", async () => {
await withCaptureParent(async (captureParent) => {
const outcome = await captureUnknownClaudeCategories({
captureParent,
subtype: "future_subtype",
terminalReason: "future_terminal_reason",
knownSubtypes,
knownTerminalReasons,
});

const [directoryName] = await readdir(captureParent);
if (directoryName === undefined) throw new Error("Expected a capture directory.");
const directory = join(captureParent, directoryName);
const file = join(directory, "categories.json");
const contents = await readFile(file, "utf8");

expect(outcome).toBe("saved");
expect(directoryName).toMatch(/^claude-category-[0-9a-f-]{36}$/u);
expect((await stat(directory)).mode & 0o777).toBe(0o700);
expect((await stat(file)).mode & 0o777).toBe(0o600);
expect(JSON.parse(contents)).toEqual({
subtype: "future_subtype",
terminal_reason: "future_terminal_reason",
});
expect(contents).not.toContain("path");
expect(contents).not.toContain("session");
});
});

it("excludes known, missing, non-string, and malformed values without writing", async () => {
await withCaptureParent(async (captureParent) => {
await expect(
captureUnknownClaudeCategories({
captureParent,
subtype: "error_max_turns",
terminalReason: { marker: "private-malformed-category" },
knownSubtypes,
knownTerminalReasons,
}),
).resolves.toBe("not-needed");
await expect(readdir(captureParent)).resolves.toEqual([]);

await expect(
captureUnknownClaudeCategories({
captureParent,
subtype: undefined,
terminalReason: "provider prose marker",
knownSubtypes,
knownTerminalReasons,
}),
).resolves.toBe("not-needed");
await expect(readdir(captureParent)).resolves.toEqual([]);
});
});

it("fails closed on per-field byte-limit overflow without a partial capture", async () => {
await withCaptureParent(async (captureParent) => {
const oversizedSubtype = `future_${"x".repeat(maximumLocalClaudeSubtypeBytes)}`;
const oversizedTerminalReason = `future_${"x".repeat(maximumLocalClaudeTerminalReasonBytes)}`;

await expect(
captureUnknownClaudeCategories({
captureParent,
subtype: "future_valid_subtype",
terminalReason: oversizedTerminalReason,
knownSubtypes,
knownTerminalReasons,
}),
).resolves.toBe("failed");
await expect(
captureUnknownClaudeCategories({
captureParent,
subtype: oversizedSubtype,
terminalReason: undefined,
knownSubtypes,
knownTerminalReasons,
}),
).resolves.toBe("failed");
await expect(readdir(captureParent)).resolves.toEqual([]);
expect(oversizedSubtype.length).toBeGreaterThan(maximumLocalClaudeSubtypeBytes);
expect(oversizedTerminalReason.length).toBeGreaterThan(maximumLocalClaudeTerminalReasonBytes);
});
});

it("fails without creating a fallback for an unavailable explicit parent", async () => {
await withCaptureParent(async (parent) => {
const unavailableParent = join(parent, "missing-parent");

await expect(
captureUnknownClaudeCategories({
captureParent: unavailableParent,
subtype: "future_subtype",
terminalReason: undefined,
knownSubtypes,
knownTerminalReasons,
}),
).resolves.toBe("failed");
await expect(readdir(parent)).resolves.toEqual([]);
});
});
});
94 changes: 94 additions & 0 deletions packages/providers/src/claude-category-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import { chmod, lstat, mkdir, open, realpath, rm } from "node:fs/promises";
import { join } from "node:path";

export const maximumLocalClaudeSubtypeBytes = 128;
export const maximumLocalClaudeTerminalReasonBytes = 128;

const categoryTokenPattern = /^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$/u;

export type LocalClaudeCategoryCaptureOutcome = "not-needed" | "saved" | "failed";

export interface LocalClaudeCategoryCaptureInput {
readonly captureParent: string;
readonly subtype: unknown;
readonly terminalReason: unknown;
readonly knownSubtypes: ReadonlySet<string>;
readonly knownTerminalReasons: ReadonlySet<string>;
}

interface SelectedCategory {
readonly value?: string;
readonly oversized: boolean;
}

function selectUnknownCategory(
value: unknown,
knownValues: ReadonlySet<string>,
maximumBytes: number,
): SelectedCategory {
if (
typeof value !== "string" ||
value.length === 0 ||
knownValues.has(value) ||
!categoryTokenPattern.test(value)
) {
return { oversized: false };
}
if (Buffer.byteLength(value, "utf8") > maximumBytes) return { oversized: true };
return { value, oversized: false };
}

export async function captureUnknownClaudeCategories(
input: LocalClaudeCategoryCaptureInput,
): Promise<LocalClaudeCategoryCaptureOutcome> {
const subtype = selectUnknownCategory(
input.subtype,
input.knownSubtypes,
maximumLocalClaudeSubtypeBytes,
);
const terminalReason = selectUnknownCategory(
input.terminalReason,
input.knownTerminalReasons,
maximumLocalClaudeTerminalReasonBytes,
);
const capturedValues = {
...(subtype.value === undefined ? {} : { subtype: subtype.value }),
...(terminalReason.value === undefined ? {} : { terminal_reason: terminalReason.value }),
};
if (subtype.oversized || terminalReason.oversized) return "failed";
if (Object.keys(capturedValues).length === 0) {
return "not-needed";
}

let createdDirectory: string | undefined;
try {
const parentInfo = await lstat(input.captureParent);
if (!parentInfo.isDirectory()) return "failed";
const parent = await realpath(input.captureParent);
const directory = join(parent, `claude-category-${randomUUID()}`);
await mkdir(directory, { mode: 0o700 });
createdDirectory = directory;
await chmod(directory, 0o700);

const filePath = join(directory, "categories.json");
const file = await open(filePath, "wx", 0o600);
try {
await file.writeFile(JSON.stringify(capturedValues), "utf8");
await file.sync();
} finally {
await file.close();
}
await chmod(filePath, 0o600);
return "saved";
} catch {
if (createdDirectory !== undefined) {
try {
await rm(createdDirectory, { recursive: true, force: true });
} catch {
// Keep the capture failure content-free even if cleanup also fails.
}
}
return "failed";
}
}
Loading